import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalMindSpaceSpaceRoutes } from './portal-mindspace-space-routes.mjs'; function createRouterRecorder() { const routes = new Map(); return { routes, get(path, handler) { routes.set(`GET ${path}`, handler); }, post(path, handler) { routes.set(`POST ${path}`, handler); }, }; } function createResponseRecorder() { return { statusCode: 200, body: undefined, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, }; } function createRequest(overrides = {}) { return { body: {}, params: {}, query: {}, currentUser: { id: 'user-1', username: 'alice', slug: 'alice-slug', }, ip: '127.0.0.1', ...overrides, }; } function createDependencies(overrides = {}) { const calls = { data: [], errors: [], routeErrors: [], enabled: [], }; return { calls, dependencies: { ensureMindSpaceEnabled(res, req) { calls.enabled.push({ res, req }); return true; }, sendData(res, req, data) { calls.data.push({ res, req, data }); return res.json({ data }); }, sendError(res, req, status, code, message) { calls.errors.push({ res, req, status, code, message }); return res.status(status).json({ error: { code, message } }); }, handleMindSpaceError(res, req, error) { calls.routeErrors.push({ res, req, error }); return res.status(418).json({ mapped: error.message }); }, ...overrides, }, }; } test('MindSpace space module preserves route inventory and order', () => { const api = createRouterRecorder(); attachPortalMindSpaceSpaceRoutes(api); assert.deepEqual([...api.routes.keys()], [ 'GET /mindspace/v1/space', 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', 'POST /mindspace/v1/schedule/reminders/bulk-delete', 'GET /mindspace/v1/space/quota', 'GET /mindspace/v1/space/categories', 'GET /mindspace/v1/space/cleanup', 'POST /mindspace/v1/space/cleanup', ]); }); test('GET /mindspace/v1/space preserves availability, missing, and success behavior', async () => { const unavailableApi = createRouterRecorder(); const unavailable = createDependencies(); attachPortalMindSpaceSpaceRoutes(unavailableApi, unavailable.dependencies); const unavailableRes = createResponseRecorder(); await unavailableApi.routes.get('GET /mindspace/v1/space')( createRequest(), unavailableRes, ); assert.equal(unavailableRes.body, undefined); assert.equal(unavailable.calls.enabled.length, 0); const missingApi = createRouterRecorder(); const missing = createDependencies({ getMindSpace: () => ({ getSpace: async () => null }), }); attachPortalMindSpaceSpaceRoutes(missingApi, missing.dependencies); const missingReq = createRequest(); const missingRes = createResponseRecorder(); await missingApi.routes.get('GET /mindspace/v1/space')(missingReq, missingRes); assert.equal(missingRes.statusCode, 404); assert.equal(missing.calls.errors[0].code, 'resource_not_found'); const space = { id: 'space-1' }; const successApi = createRouterRecorder(); const success = createDependencies({ getMindSpace: () => ({ async getSpace(userId) { assert.equal(userId, 'user-1'); return space; }, }), }); attachPortalMindSpaceSpaceRoutes(successApi, success.dependencies); const successReq = createRequest(); const successRes = createResponseRecorder(); await successApi.routes.get('GET /mindspace/v1/space')(successReq, successRes); assert.deepEqual(success.calls.data[0], { res: successRes, req: successReq, data: space, }); }); test('schedule ignore preserves feature gate, forwarding, and error mapping', async () => { const unavailableApi = createRouterRecorder(); const unavailable = createDependencies({ ensureMindSpaceEnabled: () => false, }); attachPortalMindSpaceSpaceRoutes(unavailableApi, unavailable.dependencies); const unavailableRes = createResponseRecorder(); await unavailableApi.routes.get( 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', )(createRequest(), unavailableRes); assert.equal(unavailableRes.statusCode, 503); assert.equal(unavailable.calls.errors[0].code, 'feature_disabled'); let received = null; const successApi = createRouterRecorder(); const success = createDependencies({ getScheduleService: () => ({ async cancelReminder(input) { received = input; return { id: input.reminderId, status: 'cancelled' }; }, }), }); attachPortalMindSpaceSpaceRoutes(successApi, success.dependencies); const successRes = createResponseRecorder(); await successApi.routes.get( 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', )(createRequest({ params: { reminderId: 'reminder-1' } }), successRes); assert.deepEqual(received, { userId: 'user-1', reminderId: 'reminder-1' }); assert.equal(success.calls.data[0].data.status, 'cancelled'); const failureApi = createRouterRecorder(); const failure = createDependencies({ getScheduleService: () => ({ async cancelReminder() { throw new Error('cannot cancel'); }, }), }); attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); const failureRes = createResponseRecorder(); await failureApi.routes.get( 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', )(createRequest({ params: { reminderId: 'reminder-2' } }), failureRes); assert.equal(failureRes.statusCode, 400); assert.equal(failure.calls.errors[0].message, 'cannot cancel'); }); test('schedule bulk delete preserves id normalization and error mapping', async () => { let received = null; const api = createRouterRecorder(); const success = createDependencies({ getScheduleService: () => ({ async deleteReminders(input) { received = input; return 2; }, }), }); attachPortalMindSpaceSpaceRoutes(api, success.dependencies); const res = createResponseRecorder(); await api.routes.get('POST /mindspace/v1/schedule/reminders/bulk-delete')( createRequest({ body: { ids: [1, '2'] } }), res, ); assert.deepEqual(received, { userId: 'user-1', reminderIds: ['1', '2'] }); assert.deepEqual(success.calls.data[0].data, { deleted: 2 }); const failureApi = createRouterRecorder(); const failure = createDependencies({ getScheduleService: () => ({ async deleteReminders() { throw new Error('cannot delete'); }, }), }); attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); const failureRes = createResponseRecorder(); await failureApi.routes.get('POST /mindspace/v1/schedule/reminders/bulk-delete')( createRequest(), failureRes, ); assert.equal(failureRes.statusCode, 400); assert.equal(failure.calls.errors[0].message, 'cannot delete'); }); test('quota and categories preserve unavailable, missing, and success envelopes', async () => { for (const [path, methodName] of [ ['GET /mindspace/v1/space/quota', 'getQuota'], ['GET /mindspace/v1/space/categories', 'listCategories'], ]) { const unavailableApi = createRouterRecorder(); attachPortalMindSpaceSpaceRoutes(unavailableApi); const unavailableRes = createResponseRecorder(); await unavailableApi.routes.get(path)(createRequest(), unavailableRes); assert.equal(unavailableRes.statusCode, 503); assert.deepEqual(unavailableRes.body, { message: 'MindSpace 未启用' }); const missingApi = createRouterRecorder(); attachPortalMindSpaceSpaceRoutes(missingApi, { getMindSpace: () => ({ [methodName]: async () => null }), }); const missingRes = createResponseRecorder(); await missingApi.routes.get(path)(createRequest(), missingRes); assert.equal(missingRes.statusCode, 404); assert.deepEqual(missingRes.body, { message: '用户空间不存在' }); const value = methodName === 'getQuota' ? { used: 1 } : [{ code: 'notes' }]; const successApi = createRouterRecorder(); attachPortalMindSpaceSpaceRoutes(successApi, { getMindSpace: () => ({ [methodName]: async () => value }), }); const successRes = createResponseRecorder(); await successApi.routes.get(path)(createRequest(), successRes); assert.deepEqual(successRes.body, { data: value }); } }); test('GET cleanup preserves username choice, byte total, and route error mapping', async () => { let received = null; const items = [{ id: 'a', sizeBytes: 10 }, { id: 'b', sizeBytes: 25 }]; const api = createRouterRecorder(); const success = createDependencies({ getMindSpaceCleanup: () => ({ async listCandidates(userId, username) { received = { userId, username }; return items; }, }), }); attachPortalMindSpaceSpaceRoutes(api, success.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/space/cleanup')(createRequest(), res); assert.deepEqual(received, { userId: 'user-1', username: 'alice' }); assert.deepEqual(success.calls.data[0].data, { items, totalBytes: 35 }); const failureApi = createRouterRecorder(); const failureError = new Error('cleanup list failed'); const failure = createDependencies({ getMindSpaceCleanup: () => ({ async listCandidates() { throw failureError; }, }), }); attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); const failureReq = createRequest(); const failureRes = createResponseRecorder(); await failureApi.routes.get('GET /mindspace/v1/space/cleanup')( failureReq, failureRes, ); assert.deepEqual(failure.calls.routeErrors[0], { res: failureRes, req: failureReq, error: failureError, }); }); test('POST cleanup preserves cleanup, quota, audit, response, and error flow', async () => { const calls = []; const api = createRouterRecorder(); const success = createDependencies({ getMindSpaceCleanup: () => ({ async runCleanup(userId, username, itemIds) { calls.push({ kind: 'cleanup', userId, username, itemIds }); return { removedCount: 2, freedBytes: 64 }; }, }), getMindSpace: () => ({ async getQuota(userId) { calls.push({ kind: 'quota', userId }); return { usedBytes: 100 }; }, }), getMindSpaceAudit: () => ({ async write(entry) { calls.push({ kind: 'audit', entry }); }, }), }); attachPortalMindSpaceSpaceRoutes(api, success.dependencies); const req = createRequest({ body: { item_ids: ['a', 'b'] }, ip: '10.0.0.5' }); const res = createResponseRecorder(); await api.routes.get('POST /mindspace/v1/space/cleanup')(req, res); assert.deepEqual(calls[0], { kind: 'cleanup', userId: 'user-1', username: 'alice', itemIds: ['a', 'b'], }); assert.deepEqual(calls[1], { kind: 'quota', userId: 'user-1' }); assert.deepEqual(calls[2], { kind: 'audit', entry: { userId: 'user-1', action: 'space.cleanup', objectType: 'space', objectId: 'user-1', ip: '10.0.0.5', detail: { removedCount: 2, freedBytes: 64 }, }, }); assert.deepEqual(success.calls.data[0].data, { removedCount: 2, freedBytes: 64, quota: { usedBytes: 100 }, }); const failureApi = createRouterRecorder(); const failureError = new Error('cleanup failed'); const failure = createDependencies({ getMindSpaceCleanup: () => ({ async runCleanup() { throw failureError; }, }), }); attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); const failureReq = createRequest(); const failureRes = createResponseRecorder(); await failureApi.routes.get('POST /mindspace/v1/space/cleanup')( failureReq, failureRes, ); assert.deepEqual(failure.calls.routeErrors[0], { res: failureRes, req: failureReq, error: failureError, }); });