887 lines
30 KiB
JavaScript
887 lines
30 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]);
|
|
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
|
|
};
|
|
}
|