496 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|