import { ValidationPipe, type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'node:http'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import type { ApiSuccessResponse, OldItemDonationItem, OldItemNeedItem, OldItemStats, OldItemInventoryItem, OldItemPointAccountItem, OldItemRewardsSummary, WechatLoginResponse, } from '@lehuo/contracts'; import { AppModule } from '../src/app.module'; import { PasswordService } from '../src/auth/password.service'; import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants'; import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; import { PrismaService } from '../src/database/prisma.service'; class OldItemsWechatAdapter implements WechatAuthAdapter { exchangeCode(code: string) { return Promise.resolve({ openid: `old-items-openid-${code}`, unionid: `old-items-unionid-${code}`, }); } } const data = (r: { body: unknown }) => (r.body as ApiSuccessResponse).data; function cookie(r: { headers: Record }) { const raw = r.headers['set-cookie']; const values = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []; const item = values.find((x) => x.startsWith(`${ADMIN_SESSION_COOKIE}=`)); if (!item) throw new Error('Admin cookie missing'); return item.split(';')[0]!; } describe('old item reuse Phases 1-3', () => { let app: INestApplication, prisma: PrismaService, passwords: PasswordService; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(WECHAT_AUTH_ADAPTER) .useValue(new OldItemsWechatAdapter()) .compile(); app = module.createNestApplication(); app.use(cookieParser()); app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), ); await app.init(); prisma = app.get(PrismaService); passwords = app.get(PasswordService); }); beforeEach(async () => { await prisma.oldItemPointEntry.deleteMany(); await prisma.oldItemPointAccount.deleteMany(); await prisma.oldItemCertificate.deleteMany(); await prisma.oldItemRewardConfig.deleteMany(); await prisma.oldItemInventoryEvent.deleteMany(); await prisma.oldItemInventoryItem.deleteMany(); await prisma.oldItemInventoryImport.deleteMany(); await prisma.oldItemDonationFile.deleteMany(); await prisma.oldItemDonationStatusHistory.deleteMany(); await prisma.oldItemDonation.deleteMany(); await prisma.oldItemNeedStatusHistory.deleteMany(); await prisma.oldItemNeed.deleteMany(); await prisma.oldItemDropOffPoint.deleteMany(); await prisma.auditLog.deleteMany(); await prisma.authSession.deleteMany(); await prisma.userRole.deleteMany(); await prisma.adminCredential.deleteMany(); await prisma.staffProfile.deleteMany(); await prisma.wechatIdentity.deleteMany(); }); afterAll(async () => { await prisma.oldItemPointEntry.deleteMany(); await prisma.oldItemPointAccount.deleteMany(); await prisma.oldItemCertificate.deleteMany(); await prisma.oldItemRewardConfig.deleteMany(); await prisma.oldItemInventoryEvent.deleteMany(); await prisma.oldItemInventoryItem.deleteMany(); await prisma.oldItemInventoryImport.deleteMany(); await prisma.oldItemDonationFile.deleteMany(); await prisma.oldItemDonationStatusHistory.deleteMany(); await prisma.oldItemDonation.deleteMany(); await prisma.oldItemNeedStatusHistory.deleteMany(); await prisma.oldItemNeed.deleteMany(); await prisma.oldItemDropOffPoint.deleteMany(); await prisma.auditLog.deleteMany(); await prisma.authSession.deleteMany(); await prisma.userRole.deleteMany(); await prisma.adminCredential.deleteMany(); await prisma.staffProfile.deleteMany(); await prisma.wechatIdentity.deleteMany(); await app.close(); }); async function admin() { const username = `old.${crypto.randomUUID()}`, password = 'correct horse battery staple', role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } }), user = await prisma.user.create({ data: { publicId: crypto.randomUUID(), userType: 'STAFF', nickname: '旧物管理员', staffProfile: { create: { displayName: '旧物管理员' } }, adminCredential: { create: { username, passwordHash: await passwords.hash(password) } }, }, }); await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); return { publicId: user.publicId, cookie: cookie( await request(app.getHttpServer() as Server) .post('/api/v1/admin/auth/login') .set('origin', 'http://localhost:5173') .send({ username, password }) .expect(201), ), }; } async function wechat(code: string) { const r = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code }) .expect(201); return data(r).accessToken; } it('publishes a need and completes an auditable directional pickup donation', async () => { const adminSession = await admin(), adminCookie = adminSession.cookie; const rewardConfigRequestId = crypto.randomUUID(); const rewardConfigPayload = { clientRequestId: rewardConfigRequestId, certificateTitle: '旧物善用爱心证书', certificateMessage: '感谢 {recipient} 捐赠 {quantity} 件{category}物资,公益编号 {donationNo}。', issuerName: '乐活公益', legalNotice: '本证书仅记录公益参与,不作为捐赠票据、税务凭证或财产权利证明。', clothingPoints: 1, booksPoints: 3, householdPoints: 1, sportsPoints: 1, maxPointsPerDonation: 10, }; await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/reward-config/activate') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(rewardConfigPayload) .expect(201); expect( data<{ idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/reward-config/activate') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(rewardConfigPayload) .expect(201), ).idempotentReplay, ).toBe(true); const createNeedResponse = await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/needs') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), title: '社区儿童学习用品需求', category: 'BOOKS', regionText: '丹阳市云阳街道', beneficiaryGroup: '困境儿童', description: '需要内容健康、无严重破损的课外读物', requestedQuantity: 20, priority: 80, sortOrder: 10, }); if (createNeedResponse.status !== 201) throw new Error( `create need failed: ${createNeedResponse.status} ${JSON.stringify(createNeedResponse.body)}`, ); const createdNeed = data<{ need: OldItemNeedItem }>(createNeedResponse).need; const published = data<{ need: OldItemNeedItem }>( await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/needs/${createdNeed.publicId}/transition`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), expectedRevision: createdNeed.revision, targetStatus: 'PUBLISHED', }) .expect(201), ).need; expect( data<{ items: OldItemNeedItem[] }>( await request(app.getHttpServer() as Server) .get('/api/v1/old-items/needs?page=1&pageSize=20') .expect(200), ).items, ).toHaveLength(1); const token = await wechat('donor-one'), requestId = crypto.randomUUID(), appointmentAt = new Date(Date.now() + 86_400_000).toISOString(); const payload = { clientRequestId: requestId, category: 'BOOKS', itemCondition: 'GOOD', quantity: 6, description: '六本适龄儿童读物,页面完整', method: 'PICKUP', contactName: '爱心用户', mobile: '13812345678', address: '丹阳市测试路 1 号,仅供集成测试', appointmentAt, targetNeedPublicId: published.publicId, imagePublicIds: [], }; const created = data<{ donation: OldItemDonationItem }>( await request(app.getHttpServer() as Server) .post('/api/v1/old-items/donations') .set('authorization', `Bearer ${token}`) .send(payload) .expect(201), ).donation; const replay = data<{ donation: OldItemDonationItem; idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post('/api/v1/old-items/donations') .set('authorization', `Bearer ${token}`) .send(payload) .expect(201), ); expect(created.donationNo).toMatch(/^OI-/); expect(replay.idempotentReplay).toBe(true); let current = created; for (const step of [ { targetStatus: 'WAITING_PICKUP', assignedUserPublicId: adminSession.publicId }, { targetStatus: 'RECEIVED' }, { targetStatus: 'PROCESSING' }, { targetStatus: 'COMPLETED', assistedPeople: 3 }, ]) { current = data<{ donation: OldItemDonationItem }>( await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/donations/${created.publicId}/transition`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), expectedRevision: current.revision, ...step, }) .expect(201), ).donation; } expect(current.status).toBe('COMPLETED'); expect(current.history).toHaveLength(5); const stats = data( await request(app.getHttpServer() as Server) .get('/api/v1/old-items/stats') .expect(200), ); expect(stats.totalItems).toBe(6); expect(stats.assistedPeople).toBe(3); const rewards = data( await request(app.getHttpServer() as Server) .get('/api/v1/old-items/me/rewards') .set('authorization', `Bearer ${token}`) .expect(200), ); expect(rewards.balance).toBe(10); expect(rewards.certificates).toHaveLength(1); expect(rewards.certificates[0]?.title).toBe('旧物善用爱心证书'); expect(rewards.entries[0]?.pointsDelta).toBe(10); await request(app.getHttpServer() as Server) .get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`) .set('authorization', `Bearer ${token}`) .expect(200); const otherToken = await wechat('other-donor'); await request(app.getHttpServer() as Server) .get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`) .set('authorization', `Bearer ${otherToken}`) .expect(404); const accountPage = data<{ items: OldItemPointAccountItem[] }>( await request(app.getHttpServer() as Server) .get('/api/v1/admin/old-items/reward-accounts?page=1&pageSize=20') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .expect(200), ); expect(accountPage.items).toHaveLength(1); const account = accountPage.items[0]!; const adjustment = { clientRequestId: crypto.randomUUID(), expectedRevision: account.revision, pointsDelta: 5, reason: '集成测试人工校正', }; await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(adjustment) .expect(201); expect( data<{ idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(adjustment) .expect(201), ).idempotentReplay, ).toBe(true); expect( data( await request(app.getHttpServer() as Server) .get('/api/v1/old-items/me/rewards') .set('authorization', `Bearer ${token}`) .expect(200), ).balance, ).toBe(15); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), expectedRevision: account.revision + 1, pointsDelta: -16, reason: '不允许出现负积分', }) .expect(400); const reissued = data<{ certificate: { publicId: string }; idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/donations/${created.publicId}/certificate`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID() }) .expect(201), ); expect(reissued.idempotentReplay).toBe(false); expect(await prisma.oldItemCertificate.count({ where: { status: 'REVOKED' } })).toBe(1); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/certificates/${reissued.certificate.publicId}/revoke`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), reason: '集成测试撤销' }) .expect(201); expect(await prisma.oldItemCertificate.count({ where: { status: 'ISSUED' } })).toBe(0); expect( await prisma.auditLog.count({ where: { resourceType: { in: [ 'OLD_ITEM_DONATION', 'OLD_ITEM_NEED', 'OLD_ITEM_REWARD_CONFIG', 'OLD_ITEM_CERTIFICATE', 'OLD_ITEM_POINT_ENTRY', ], }, }, }), ).toBeGreaterThanOrEqual(12); }); it('runs the inventory ledger state machine and atomically imports and exports rows', async () => { const adminCookie = (await admin()).cookie; const requestId = crypto.randomUUID(); const payload = { clientRequestId: requestId, category: 'CLOTHING', itemName: '冬季外套', quantity: 12, unit: '件', storageLocation: '测试仓 A-01', note: '集成测试入库', }; const createdResponse = await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/inventory') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(payload) .expect(201); let current = data<{ item: OldItemInventoryItem }>(createdResponse).item; expect(current.inventoryNo).toMatch(/^INV-/); expect(current.events).toHaveLength(1); expect( data<{ idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/inventory') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(payload) .expect(201), ).idempotentReplay, ).toBe(true); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), expectedRevision: current.revision, targetStatus: 'DISINFECTED', }) .expect(409); for (const step of [ { targetStatus: 'SORTED' }, { targetStatus: 'DISINFECTED' }, { targetStatus: 'OUTBOUND', destinationRegion: '丹阳市云阳街道' }, { targetStatus: 'DELIVERED', assistedPeople: 5 }, ]) { current = data<{ item: OldItemInventoryItem }>( await request(app.getHttpServer() as Server) .post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`) .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send({ clientRequestId: crypto.randomUUID(), expectedRevision: current.revision, ...step, }) .expect(201), ).item; } expect(current.status).toBe('DELIVERED'); expect(current.events.map((x) => x.status)).toEqual([ 'IN_STOCK', 'SORTED', 'DISINFECTED', 'OUTBOUND', 'DELIVERED', ]); const importId = crypto.randomUUID(); const importPayload = { clientRequestId: importId, originalFilename: '物资入库.xlsx', rows: [ { category: 'BOOKS', itemName: '儿童读物', quantity: 20, unit: '本', storageLocation: '测试仓 B-01', }, { category: 'SPORTS', itemName: '跳绳', quantity: 8, unit: '根', storageLocation: '测试仓 B-02', }, ], }; const imported = data<{ items: OldItemInventoryItem[]; idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/inventory/import') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(importPayload) .expect(201), ); expect(imported.items).toHaveLength(2); expect( data<{ idempotentReplay: boolean }>( await request(app.getHttpServer() as Server) .post('/api/v1/admin/old-items/inventory/import') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .send(importPayload) .expect(201), ).idempotentReplay, ).toBe(true); const exported = data<{ rows: unknown[] }>( await request(app.getHttpServer() as Server) .get('/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100') .set('origin', 'http://localhost:5173') .set('cookie', adminCookie) .expect(200), ); expect(exported.rows).toHaveLength(3); expect(await prisma.oldItemInventoryEvent.count()).toBe(7); expect( await prisma.auditLog.count({ where: { resourceType: { startsWith: 'OLD_ITEM_INVENTORY' } }, }), ).toBeGreaterThanOrEqual(7); }); });