import assert from 'node:assert/strict'; import { buildApp } from '../dist/app.js'; import { signAccessToken } from '../dist/auth/jwt.js'; const secret = 'test-only-cleaning-route-secret'; const token = signAccessToken({ sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900); const forbiddenToken = signAccessToken({ sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900); const calls = []; const app = await buildApp({ cleaning: { jwtSecret: secret, authRepository: { async validateSession(sessionId) { return { id: sessionId, tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: sessionId.startsWith('8') ? '32' : '31', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } }, accessControl: { async getAccessProfile(tenantId, userId) { return userId === '31' ? { roles: ['CLEANER'], capabilities: [ 'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read', 'cleaning.settlement.read', 'cleaning.settlement.write' ], storeIds: ['11'] } : { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }; } }, repository: { async listHall(input) { calls.push(['listHall', input]); return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize }; }, async listMine(input) { calls.push(['listMine', input]); return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize }; }, async listManage(input) { calls.push(['listManage', input]); return { items: [task(input.status || 'SUBMITTED')], total: 1, page: input.page, pageSize: input.pageSize }; }, async claim(input) { calls.push(['claim', input]); return task('CLAIMED'); }, async start(input) { calls.push(['start', input]); return task('STARTED'); }, async rework(input) { calls.push(['rework', input]); return task('STARTED'); }, async assertCanUploadPhoto(input) { calls.push(['assertCanUploadPhoto', input]); return { storeId: '11', maxPhotoCount: 3 }; }, async recordPhotoUpload(input) { calls.push(['recordPhotoUpload', input]); return { id: '701', ...input.image }; }, async listTemplates(input) { calls.push(['listTemplates', input]); return [{ id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null, name: '门店清洁', requirement: '桌面与地面', photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', version: 2, status: 'ACTIVE', appliesToExistingTasks: false }]; }, async upsertTemplate(input) { calls.push(['upsertTemplate', input]); return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input, storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false }; }, async submit(input) { calls.push(['submit', input]); return { ...task('SUBMITTED'), photoUrls: input.photoUrls }; }, async assign(input) { calls.push(['assign', input]); return { ...task('CLAIMED'), cleanerUserId: input.cleanerUserId }; }, async complete(input) { calls.push(['complete', input]); return task('COMPLETED'); }, async reject(input) { calls.push(['reject', input]); return { ...task('REJECTED'), rejectReason: input.reason }; }, async exempt(input) { calls.push(['exempt', input]); return { ...task('EXEMPT'), rewardCents: 0 }; }, async listMembers(input) { calls.push(['listMembers', input]); return [member('LEAD', '31', 500), member('ASSIST', '33', 100)]; }, async listEvents(input) { calls.push(['listEvents', input]); return [{ id: '9001', taskId: input.taskId, fromStatus: 'STARTED', toStatus: 'SUBMITTED', action: 'SUBMIT', actorId: input.userId, traceId: input.traceId, note: 'done', createdAt: new Date() }]; }, async listSubmissions(input) { calls.push(['listSubmissions', input]); return [{ id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED', photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first', rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31', submittedAt: new Date(), reviewedAt: new Date() }]; }, async addMember(input) { calls.push(['addMember', input]); return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)]; }, async removeMember(input) { calls.push(['removeMember', input]); return [member('LEAD', '31', 600)]; }, async settlementCandidates(input) { calls.push(['settlementCandidates', input]); return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize }; }, async listSettlements(input) { calls.push(['listSettlements', input]); return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize }; }, async exportSettlements(input) { calls.push(['exportSettlements', input]); return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false }; }, async getSettlementDetail(input) { calls.push(['getSettlementDetail', input]); return { settlement: settlementResponse('DRAFT'), items: [settlementItemResponse()], events: [{ id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT', action: 'GENERATE', actorId: input.userId, traceId: input.traceId, note: '', createdAt: new Date() }], reversals: [] }; }, async generateSettlement(input) { calls.push(['generateSettlement', input]); return settlementResponse('DRAFT'); }, async confirmSettlement(input) { calls.push(['confirmSettlement', input]); return settlementResponse('CONFIRMED'); }, async cancelSettlement(input) { calls.push(['cancelSettlement', input]); return { ...settlementResponse('CANCELLED'), cancelledBy: input.userId, cancelledAt: new Date(), note: input.reason }; }, async markSettlementPaid(input) { calls.push(['markSettlementPaid', input]); return { ...settlementResponse('PAID'), paidBy: input.userId, paidAt: new Date(), payoutChannel: input.payoutChannel, payoutReference: input.payoutReference, payoutError: '' }; }, async recordSettlementPayoutFailure(input) { calls.push(['recordSettlementPayoutFailure', input]); return { ...settlementResponse('CONFIRMED'), payoutChannel: input.payoutChannel || '', payoutReference: input.payoutReference || '', payoutError: input.error }; }, async reclaimTimeouts(input) { calls.push(['reclaimTimeouts', input]); return { reclaimed: 2, taskIds: ['101', '102'] }; }, async claimExpiredPhotoUploads(input) { calls.push(['claimExpiredPhotoUploads', input]); return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }]; }, async markPhotoUploadsDeleted(input) { calls.push(['markPhotoUploadsDeleted', input]); return { deleted: input.photoIds.length }; }, async stats(input) { calls.push(['stats', input]); return { byStatus: { SUBMITTED: 2, COMPLETED: 1, REJECTED: 1 }, taskTotal: 4, completed: 1, rejected: 1, completionRate: 25, pendingSettlementCents: 1200, settledRewardCents: 600 }; }, async managerStatistics(input) { calls.push(['managerStatistics', input]); return { summary: { taskTotal: 5, pendingReview: 2, active: 1, rejected: 1, exempted: 1, completed: 1, rewardCents: 3000, pendingSettlementCents: 1200, confirmedSettlementCents: 800, paidSettlementCents: 600 }, byStatus: [{ status: 'SUBMITTED', total: 2, rewardCents: 1200 }], byStore: [{ storeId: '11', storeName: 'Test Store', taskTotal: 5, pendingReview: 2, completed: 1, rejected: 1, exempted: 1, rewardCents: 3000 }], settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }], members: [{ cleanerUserId: '31', cleanerName: 'Cleaner', taskCount: 3, completedTaskCount: 2, rejectedTaskCount: 1, pendingSettlementCents: 1200, settledRewardCents: 600 }], trend: [{ date: '2026-06-30', taskTotal: 5, pendingReview: 2, completed: 1, rejected: 1, exempted: 1, rewardCents: 3000, paidSettlementCents: 600 }] }; } }, payoutService: { async preflightWechatTransfer(input) { calls.push(['preflightWechatTransfer', input]); return { ready: true, settlement: { id: input.settlementId, settlementNo: 'CLS-501', status: 'CONFIRMED' }, account: { configured: true, merchantIdMasked: '19***0109' }, credential: { configured: true, transferSceneId: '1000', reportInfoCount: 1 }, cleaner: { openidConfigured: true }, checks: [{ key: 'transfer_scene_id', status: 'PASS', message: 'ok' }] }; }, async executeWechatTransfer(input) { calls.push(['executeWechatTransfer', input]); return { settlement: settlementResponse(input.mode === 'MOCK' ? 'PAID' : 'CONFIRMED'), transferState: input.mode === 'MOCK' ? 'SUCCESS' : 'WAIT_USER_CONFIRM', idempotent: false }; }, async syncWechatTransfer(input) { calls.push(['syncWechatTransfer', input]); return { settlement: { ...settlementResponse('PAID'), payoutChannel: 'WECHAT_TRANSFER', payoutReference: 'wx-transfer-sync-501' }, transferState: 'SUCCESS', idempotent: false }; }, async processWechatTransferNotification(headers, rawBody, traceId) { calls.push(['processWechatTransferNotification', { headers, rawBody, traceId }]); return { settlement: { ...settlementResponse('PAID'), payoutChannel: 'WECHAT_TRANSFER', payoutReference: 'wx-transfer-notify-501' }, transferState: 'SUCCESS', idempotent: false }; } }, mediaStorage: { async storeImage(input) { calls.push(['storeImage', input]); return { storagePath: 'tenants/7/stores/11/cleaning.webp', publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp', mimeType: 'image/webp', byteSize: input.body.length, width: 640, height: 480, checksumSha256: 'abc' }; }, async deleteImage(storagePath) { calls.push(['deleteImage', storagePath]); } } } }); const templates = await app.inject({ method: 'GET', url: '/admin-api/cleaning/templates', headers: { authorization: `Bearer ${token}` } }); assert.equal(templates.statusCode, 200); assert.equal(templates.json().data[0].scopeKey, 'STORE:11'); const updatedTemplate = await app.inject({ method: 'PUT', url: '/admin-api/cleaning/templates', headers: { authorization: `Bearer ${token}` }, payload: { scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面', photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE' } }); assert.equal(updatedTemplate.statusCode, 200); assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false); assert.equal(calls.at(-1)[0], 'upsertTemplate'); const hall = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10', headers: { authorization: `Bearer ${token}` } }); assert.equal(hall.statusCode, 200); assert.equal(hall.json().data.items[0].status, 'WAITING'); assert.equal(calls.at(-1)[1].tenantId, '7'); assert.equal(calls.at(-1)[1].userId, '31'); const mine = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/mine?status=CLAIMED', headers: { authorization: `Bearer ${token}` } }); assert.equal(mine.statusCode, 200); assert.equal(calls.at(-1)[0], 'listMine'); assert.equal(calls.at(-1)[1].status, 'CLAIMED'); const manageList = await app.inject({ method: 'GET', url: '/admin-api/cleaning/tasks?status=SUBMITTED&storeId=11&cleanerUserId=31', headers: { authorization: `Bearer ${token}` } }); assert.equal(manageList.statusCode, 200); assert.equal(calls.at(-1)[0], 'listManage'); assert.equal(calls.at(-1)[1].status, 'SUBMITTED'); assert.equal(calls.at(-1)[1].storeId, '11'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); const settlementExport = await app.inject({ method: 'GET', url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31', headers: { authorization: `Bearer ${token}` } }); assert.equal(settlementExport.statusCode, 200); assert.equal(calls.at(-1)[0], 'exportSettlements'); assert.equal(calls.at(-1)[1].status, 'DRAFT'); assert.equal(settlementExport.json().data.exported, 1); const appManageList = await app.inject({ method: 'GET', url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11', headers: { authorization: `Bearer ${token}` } }); assert.equal(appManageList.statusCode, 200); assert.equal(calls.at(-1)[0], 'listManage'); assert.equal(calls.at(-1)[1].storeId, '11'); const managerStats = await app.inject({ method: 'GET', url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31', headers: { authorization: `Bearer ${token}` } }); assert.equal(managerStats.statusCode, 200); assert.equal(calls.at(-1)[0], 'managerStatistics'); assert.equal(calls.at(-1)[1].storeId, '11'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); assert.equal(managerStats.json().data.summary.pendingSettlementCents, 1200); assert.equal(managerStats.json().data.summary.exempted, 1); assert.equal(managerStats.json().data.byStore[0].exempted, 1); assert.equal(managerStats.json().data.members[0].completedTaskCount, 2); assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1); assert.equal(managerStats.json().data.trend[0].exempted, 1); assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600); const appManagerStats = await app.inject({ method: 'GET', url: '/app-api/management/cleaning/statistics?storeId=11', headers: { authorization: `Bearer ${token}` } }); assert.equal(appManagerStats.statusCode, 200); assert.equal(calls.at(-1)[0], 'managerStatistics'); assert.equal(calls.at(-1)[1].storeId, '11'); const claim = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/claim', headers: { authorization: `Bearer ${token}` } }); assert.equal(claim.statusCode, 200); assert.equal(calls.at(-1)[0], 'claim'); assert.equal(calls.at(-1)[1].taskId, '101'); const start = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/start', headers: { authorization: `Bearer ${token}` } }); assert.equal(start.statusCode, 200); assert.equal(calls.at(-1)[0], 'start'); const rework = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/rework', headers: { authorization: `Bearer ${token}` } }); assert.equal(rework.statusCode, 200); assert.equal(calls.at(-1)[0], 'rework'); const photo = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/photos', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream', 'x-file-name': 'cleaning.jpg', 'x-image-content-type': 'image/jpeg' }, payload: Buffer.from('fake-image') }); assert.equal(photo.statusCode, 201); assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp'); assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto'); assert.equal(calls.at(-2)[0], 'storeImage'); assert.equal(calls.at(-2)[1].storeId, '11'); assert.equal(calls.at(-1)[0], 'recordPhotoUpload'); const submit = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/submit', headers: { authorization: `Bearer ${token}` }, payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' } }); assert.equal(submit.statusCode, 200); assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']); const assign = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/assign', headers: { authorization: `Bearer ${token}` }, payload: { cleanerUserId: '33', note: 'manual dispatch' } }); assert.equal(assign.statusCode, 200); assert.equal(calls.at(-1)[0], 'assign'); assert.equal(calls.at(-1)[1].cleanerUserId, '33'); const complete = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/complete', headers: { authorization: `Bearer ${token}` }, payload: { note: 'ok' } }); assert.equal(complete.statusCode, 200); assert.equal(calls.at(-1)[0], 'complete'); const appComplete = await app.inject({ method: 'POST', url: '/app-api/management/cleaning/tasks/101/complete', headers: { authorization: `Bearer ${token}` }, payload: { note: 'miniapp approval' } }); assert.equal(appComplete.statusCode, 200); assert.equal(calls.at(-1)[0], 'complete'); assert.equal(calls.at(-1)[1].note, 'miniapp approval'); const reject = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/reject', headers: { authorization: `Bearer ${token}` }, payload: { reason: 'photo is unclear' } }); assert.equal(reject.statusCode, 200); assert.equal(calls.at(-1)[0], 'reject'); assert.equal(calls.at(-1)[1].reason, 'photo is unclear'); const appReject = await app.inject({ method: 'POST', url: '/app-api/management/cleaning/tasks/101/reject', headers: { authorization: `Bearer ${token}` }, payload: { reason: 'miniapp asks for rework' } }); assert.equal(appReject.statusCode, 200); assert.equal(calls.at(-1)[0], 'reject'); assert.equal(calls.at(-1)[1].reason, 'miniapp asks for rework'); const exempt = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/exempt', headers: { authorization: `Bearer ${token}` }, payload: { note: 'no cleaning required' } }); assert.equal(exempt.statusCode, 200); assert.equal(exempt.json().data.status, 'EXEMPT'); assert.equal(calls.at(-1)[0], 'exempt'); assert.equal(calls.at(-1)[1].note, 'no cleaning required'); const appExempt = await app.inject({ method: 'POST', url: '/app-api/management/cleaning/tasks/101/exempt', headers: { authorization: `Bearer ${token}` }, payload: { note: 'miniapp exemption' } }); assert.equal(appExempt.statusCode, 200); assert.equal(calls.at(-1)[0], 'exempt'); assert.equal(calls.at(-1)[1].note, 'miniapp exemption'); const members = await app.inject({ method: 'GET', url: '/admin-api/cleaning/tasks/101/members', headers: { authorization: `Bearer ${token}` } }); assert.equal(members.statusCode, 200); assert.equal(calls.at(-1)[0], 'listMembers'); assert.equal(members.json().data.length, 2); const events = await app.inject({ method: 'GET', url: '/admin-api/cleaning/tasks/101/events', headers: { authorization: `Bearer ${token}` } }); assert.equal(events.statusCode, 200); assert.equal(calls.at(-1)[0], 'listEvents'); assert.equal(calls.at(-1)[1].taskId, '101'); assert.equal(events.json().data[0].action, 'SUBMIT'); const submissions = await app.inject({ method: 'GET', url: '/admin-api/cleaning/tasks/101/submissions', headers: { authorization: `Bearer ${token}` } }); assert.equal(submissions.statusCode, 200); assert.equal(calls.at(-1)[0], 'listSubmissions'); assert.equal(submissions.json().data[0].revision, 2); assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp'); const addedMember = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/members', headers: { authorization: `Bearer ${token}` }, payload: { cleanerUserId: '33', rewardCents: 100, note: 'support' } }); assert.equal(addedMember.statusCode, 200); assert.equal(calls.at(-1)[0], 'addMember'); assert.equal(calls.at(-1)[1].cleanerUserId, '33'); assert.equal(calls.at(-1)[1].rewardCents, 100); const removedMember = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/members/33/remove', headers: { authorization: `Bearer ${token}` }, payload: { note: 'done' } }); assert.equal(removedMember.statusCode, 200); assert.equal(calls.at(-1)[0], 'removeMember'); assert.equal(calls.at(-1)[1].cleanerUserId, '33'); const settlement = await app.inject({ method: 'GET', url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10&storeId=11&cleanerUserId=31', headers: { authorization: `Bearer ${token}` } }); assert.equal(settlement.statusCode, 200); assert.equal(calls.at(-1)[0], 'settlementCandidates'); assert.equal(calls.at(-1)[1].storeId, '11'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); const settlementList = await app.inject({ method: 'GET', url: '/admin-api/cleaning/settlements?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31', headers: { authorization: `Bearer ${token}` } }); assert.equal(settlementList.statusCode, 200); assert.equal(calls.at(-1)[0], 'listSettlements'); assert.equal(calls.at(-1)[1].status, 'DRAFT'); assert.equal(calls.at(-1)[1].payoutState, 'FAIL'); assert.equal(calls.at(-1)[1].storeId, '11'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); const settlementDetail = await app.inject({ method: 'GET', url: '/admin-api/cleaning/settlements/501', headers: { authorization: `Bearer ${token}` } }); assert.equal(settlementDetail.statusCode, 200); assert.equal(calls.at(-1)[0], 'getSettlementDetail'); assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001'); const generatedSettlement = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements', headers: { authorization: `Bearer ${token}` }, payload: { cleanerUserId: '31', storeId: '11', note: 'weekly settlement' } }); assert.equal(generatedSettlement.statusCode, 201); assert.equal(calls.at(-1)[0], 'generateSettlement'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); const confirmedSettlement = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/confirm', headers: { authorization: `Bearer ${token}` }, payload: { note: 'confirmed' } }); assert.equal(confirmedSettlement.statusCode, 200); assert.equal(calls.at(-1)[0], 'confirmSettlement'); assert.equal(calls.at(-1)[1].settlementId, '501'); const cancelledSettlement = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/cancel', headers: { authorization: `Bearer ${token}` }, payload: { reason: 'duplicate weekly batch' } }); assert.equal(cancelledSettlement.statusCode, 200); assert.equal(cancelledSettlement.json().data.status, 'CANCELLED'); assert.equal(calls.at(-1)[0], 'cancelSettlement'); assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch'); const failedPayout = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/payout-failure', headers: { authorization: `Bearer ${token}` }, payload: { payoutChannel: 'WECHAT_TRANSFER', payoutReference: 'wx-failed-001', error: 'insufficient merchant balance', note: 'retry later' } }); assert.equal(failedPayout.statusCode, 200); assert.equal(calls.at(-1)[0], 'recordSettlementPayoutFailure'); assert.equal(calls.at(-1)[1].settlementId, '501'); assert.equal(calls.at(-1)[1].error, 'insufficient merchant balance'); const paidSettlement = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/paid', headers: { authorization: `Bearer ${token}` }, payload: { payoutChannel: 'WECHAT_TRANSFER', payoutReference: 'wx-paid-001', note: 'paid' } }); assert.equal(paidSettlement.statusCode, 200); assert.equal(paidSettlement.json().data.status, 'PAID'); assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001'); assert.equal(calls.at(-1)[0], 'markSettlementPaid'); const wechatTransfer = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/wechat-transfer', headers: { authorization: `Bearer ${token}` }, payload: { mode: 'API', note: 'wechat transfer' } }); assert.equal(wechatTransfer.statusCode, 200); assert.equal(wechatTransfer.json().data.transferState, 'WAIT_USER_CONFIRM'); assert.equal(calls.at(-1)[0], 'executeWechatTransfer'); assert.equal(calls.at(-1)[1].settlementId, '501'); assert.equal(calls.at(-1)[1].mode, 'API'); const wechatTransferPreflight = await app.inject({ method: 'GET', url: '/admin-api/cleaning/settlements/501/wechat-transfer/preflight', headers: { authorization: `Bearer ${token}` } }); assert.equal(wechatTransferPreflight.statusCode, 200); assert.equal(wechatTransferPreflight.json().data.ready, true); assert.equal(wechatTransferPreflight.json().data.account.merchantIdMasked, '19***0109'); assert.equal(calls.at(-1)[0], 'preflightWechatTransfer'); assert.equal(calls.at(-1)[1].settlementId, '501'); const syncedWechatTransfer = await app.inject({ method: 'POST', url: '/admin-api/cleaning/settlements/501/wechat-transfer/sync', headers: { authorization: `Bearer ${token}` }, payload: { note: 'poll wechat transfer' } }); assert.equal(syncedWechatTransfer.statusCode, 200); assert.equal(syncedWechatTransfer.json().data.transferState, 'SUCCESS'); assert.equal(syncedWechatTransfer.json().data.settlement.payoutReference, 'wx-transfer-sync-501'); assert.equal(calls.at(-1)[0], 'syncWechatTransfer'); assert.equal(calls.at(-1)[1].settlementId, '501'); const wechatTransferNotify = await app.inject({ method: 'POST', url: '/app-api/cleaning/wechat-transfer/notify', headers: { 'content-type': 'application/json', 'wechatpay-timestamp': '1782700001', 'wechatpay-nonce': 'notify-nonce', 'wechatpay-serial': 'PLATFORM-SERIAL', 'wechatpay-signature': 'signature' }, payload: '{"resource":"encrypted"}' }); assert.equal(wechatTransferNotify.statusCode, 200); assert.equal(wechatTransferNotify.json().code, 'SUCCESS'); assert.equal(calls.at(-1)[0], 'processWechatTransferNotification'); assert.equal(calls.at(-1)[1].headers.serial, 'PLATFORM-SERIAL'); assert.equal(calls.at(-1)[1].rawBody, '{"resource":"encrypted"}'); const reclaimed = await app.inject({ method: 'POST', url: '/admin-api/cleaning/reclaim-timeouts', headers: { authorization: `Bearer ${token}` }, payload: { olderThanMinutes: 30, limit: 10 } }); assert.equal(reclaimed.statusCode, 200); assert.equal(calls.at(-1)[0], 'reclaimTimeouts'); assert.equal(calls.at(-1)[1].olderThanMinutes, 30); const cleanedPhotos = await app.inject({ method: 'POST', url: '/admin-api/cleaning/photos/cleanup', headers: { authorization: `Bearer ${token}` }, payload: { limit: 10 } }); assert.equal(cleanedPhotos.statusCode, 200); assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] }); assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads'); assert.equal(calls.at(-2)[0], 'deleteImage'); assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted'); assert.deepEqual(calls.at(-1)[1].photoIds, ['701']); const stats = await app.inject({ method: 'GET', url: '/app-api/cleaning/stats', headers: { authorization: `Bearer ${token}` } }); assert.equal(stats.statusCode, 200); assert.equal(stats.json().data.pendingSettlementCents, 1200); assert.equal(stats.json().data.settledRewardCents, 600); assert.equal(stats.json().data.completionRate, 25); const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' }); assert.equal(unauthorized.statusCode, 401); const forbidden = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall', headers: { authorization: `Bearer ${forbiddenToken}` } }); assert.equal(forbidden.statusCode, 403); await app.close(); console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.'); function task(status) { return { id: '101', taskNo: 'CLN-20260625-0001', storeId: '11', storeName: 'Test Store', roomId: '21', roomName: 'A Room', roomNo: 'A01', orderId: '301', orderNo: 'O301', status, rewardCents: 600, photoUrls: [] }; } function settlementResponse(status) { return { id: '501', settlementNo: 'CLS-20260626-0001', cleanerUserId: '31', cleanerName: 'Cleaner', storeId: '11', storeName: 'Test Store', status, taskCount: 2, totalRewardCents: 1200, periodStart: new Date(), periodEnd: new Date(), paidBy: status === 'PAID' ? '31' : null, confirmedAt: status === 'CONFIRMED' ? new Date() : null, paidAt: status === 'PAID' ? new Date() : null, payoutChannel: '', payoutReference: '', payoutError: '', note: '' }; } function settlementItemResponse() { return { id: '601', settlementId: '501', taskId: '101', taskNo: 'CLN-20260625-0001', orderNo: 'O301', storeName: 'Test Store', roomName: 'A Room', roomNo: 'A01', cleanerUserId: '31', cleanerName: 'Cleaner', rewardCents: 600, completedAt: new Date(), createdAt: new Date() }; } function member(memberRole, userId, rewardCents) { return { id: `${userId}01`, taskId: '101', userId, nickname: `Cleaner ${userId}`, memberRole, rewardCents, joinedAt: new Date(), removedAt: null, settledAt: null }; }