Files
qipai/backend/tests/cleaning-route.test.mjs
T
2026-07-05 14:55:22 +08:00

678 lines
22 KiB
JavaScript

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]);
},
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 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 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 getSettlementDetail(input) {
calls.push(['getSettlementDetail', input]);
return {
settlement: settlementResponse('DRAFT'),
items: [settlementItemResponse()]
};
},
async generateSettlement(input) {
calls.push(['generateSettlement', input]);
return settlementResponse('DRAFT');
},
async confirmSettlement(input) {
calls.push(['confirmSettlement', input]);
return settlementResponse('CONFIRMED');
},
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 stats(input) {
calls.push(['stats', input]);
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
},
async managerStatistics(input) {
calls.push(['managerStatistics', input]);
return {
summary: {
taskTotal: 5,
pendingReview: 2,
active: 1,
rejected: 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,
rewardCents: 3000
}],
settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }],
members: [{
cleanerUserId: '31',
cleanerName: 'Cleaner',
taskCount: 3,
pendingSettlementCents: 1200,
settledRewardCents: 600
}],
trend: [{
date: '2026-06-30',
taskTotal: 5,
pendingReview: 2,
completed: 1,
rejected: 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/shared/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
mimeType: 'image/webp',
byteSize: input.body.length,
width: 640,
height: 480,
checksumSha256: 'abc'
};
}
}
}
});
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 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.trend[0].paidSettlementCents, 600);
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/shared/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage');
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 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 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 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 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 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);
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
};
}