Files
lehuo/apps/api/test/old-items.integration-spec.ts
T

496 lines
19 KiB
TypeScript

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 = <T>(r: { body: unknown }) => (r.body as ApiSuccessResponse<T>).data;
function cookie(r: { headers: Record<string, unknown> }) {
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<WechatLoginResponse>(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<OldItemStats>(
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<OldItemRewardsSummary>(
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<OldItemRewardsSummary>(
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);
});
});