This commit is contained in:
浅念
2026-08-27 16:11:49 +08:00
commit 2d8ef3d7bb
104 changed files with 20196 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, teardown } from './setup.js';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
describe('auth', () => {
it('login with admin env creds returns token', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' });
expect(res.status).toBe(200);
expect(res.body.token).toMatch(/^[A-Za-z0-9._-]+$/);
});
it('wrong password 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'wrong' });
expect(res.status).toBe(401);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, resetDb, teardown } from './setup.js';
import { makePayload } from './fixtures.js';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
async function login() {
return (
await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' })
).body.token;
}
async function pushInquiry(p: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(p);
}
describe('export', () => {
it('list CSV has header + row, no chat content', async () => {
await resetDb();
await pushInquiry(makePayload()); // inquiryId 13776617920, messages hello/hi
const token = await login();
const res = await request(app).get('/api/export').set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/csv');
const text = res.text;
expect(text).toContain('询价单号'); // 表头
expect(text).toContain('13776617920'); // 数据行
expect(text).not.toContain('hello'); // 列表导出不含聊天原文
});
it('single-inquiry chat CSV contains message content', async () => {
await resetDb();
await pushInquiry(makePayload());
const token = await login();
const res = await request(app)
.get('/api/inquiries/13776617920/messages/export')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.text).toContain('时间');
expect(res.text).toContain('hello'); // 含聊天原文
});
});
+45
View File
@@ -0,0 +1,45 @@
export function makePayload(o: any = {}) {
return {
inquiry: {
inquiryId: '13776617920',
manager: 'cara Hu',
createTime: '2026-03-13T10:11:50Z',
updateTime: '2026-07-13T10:11:50Z',
status: '洽谈中',
msgPreview: 'hi',
isFlagged: false,
...o.inquiry,
},
customer: {
customerId: 'cid-1',
name: 'Carlos',
country: 'PE',
email: null,
company: 'World Shop',
registerTime: '2025-06-23T00:00:00Z',
avatarUrl: '',
buyerTags: 'TM沟通',
buyerFeatures: '',
behavior: { productViews: null, validInquiries: null, validRfq: 39 },
...o.customer,
},
messages: o.messages ?? [
{
messageId: 'm1',
sendTime: '2026-07-13T02:11:50Z',
sender: 'cara Hu',
direction: 'outgoing',
content: 'hello',
translation: '',
},
{
messageId: 'm2',
sendTime: '2026-07-13T02:12:00Z',
sender: 'Carlos',
direction: 'incoming',
content: 'hi',
translation: '',
},
],
};
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
describe('health', () => {
it('ok (200 if DB reachable, 503 if not — both acceptable, never 500)', async () => {
const res = await request(createApp()).get('/api/health');
// 单元环境无容器连不上 → 503;有容器 → 200。两者都算通过(只杜绝 500 崩)
expect([200, 503]).toContain(res.status);
expect(res.body).toHaveProperty('ok');
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, teardown, resetDb } from './setup.js';
import { makePayload } from './fixtures.js';
const TOKEN = 'Bearer dev-ingest-token-1234';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
describe('ingest auth/validation', () => {
it('401 without token', async () => {
const res = await request(app).post('/api/ingest').send(makePayload());
expect(res.status).toBe(401);
});
it('400 bad payload', async () => {
const res = await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send({ inquiry: {} });
expect(res.status).toBe(400);
});
});
describe('ingest basic insert', () => {
it('accepts valid payload, inserts 2 messages', async () => {
await resetDb();
const res = await request(app).post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234').send(makePayload());
expect(res.status).toBe(200);
expect(res.body.inserted).toBe(2);
expect(res.body.earlyReturn).toBe(false);
});
});
describe('ingest idempotency + increment', () => {
it('push N → push N again early-returns', async () => {
await resetDb();
const p = makePayload();
const r1 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p);
expect(r1.status).toBe(200);
expect(r1.body.inserted).toBe(2);
expect(r1.body.earlyReturn).toBe(false);
const r2 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p);
expect(r2.body.earlyReturn).toBe(true);
});
it('push N → push N+1 → push N+1 again early-returns (messagesCount=total regression)', async () => {
await resetDb();
const base = makePayload();
await request(app).post('/api/ingest').set('Authorization', TOKEN).send(base);
const withOneMore = makePayload({
messages: [
...base.messages,
{ messageId: 'm3', sendTime: '2026-07-13T02:13:00Z', sender: 'x', direction: 'incoming', content: 'c3', translation: '' },
],
});
const r3 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore);
expect(r3.body.inserted).toBe(3); // 总量 3,不是 delta 1
expect(r3.body.earlyReturn).toBe(false);
// 再推同样 3 条 → 早返回(若 messagesCount 退化成 delta,这里 earlyReturn 会是 false)
const r4 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore);
expect(r4.body.earlyReturn).toBe(true);
expect(r4.body.inserted).toBe(3);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, resetDb, teardown } from './setup.js';
import { makePayload } from './fixtures.js';
import { getPrisma } from '../src/lib/prisma.js';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
async function login() {
const r = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' });
return r.body.token;
}
async function pushInquiry(payload: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(payload);
}
describe('inquiries list', () => {
it('lists filtered by country', async () => {
await resetDb();
await pushInquiry(makePayload({ customer: { country: 'PE' } }));
await pushInquiry(
makePayload({ inquiry: { inquiryId: 'X2' }, customer: { customerId: 'c2', country: 'US' } }),
);
const token = await login();
const res = await request(app)
.get('/api/inquiries?country=PE')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.body.items.length).toBe(1);
expect(res.body.items[0].customer.country).toBe('PE');
expect(res.body).toHaveProperty('total');
expect(res.body).toHaveProperty('page');
expect(res.body).toHaveProperty('size');
});
it('401 without jwt', async () => {
const res = await request(app).get('/api/inquiries');
expect(res.status).toBe(401);
});
});
describe('inquiries detail', () => {
it('gets inquiry detail with customer + message count', async () => {
await resetDb();
await pushInquiry(makePayload()); // inquiryId='13776617920', 2 messages
const token = await login();
const res = await request(app)
.get('/api/inquiries/13776617920')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.body.inquiryId).toBe('13776617920');
expect(res.body.customer).toBeTruthy();
expect(res.body._count.messages).toBe(2);
});
it('404 for unknown inquiry', async () => {
const token = await login();
const res = await request(app)
.get('/api/inquiries/NOPE')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(404);
});
});
describe('inquiry messages pagination', () => {
// 造 50 条消息(sendTime 各不同),用于测默认从最新 + before 向上翻页
async function seed50() {
await resetDb();
const prisma = getPrisma();
await pushInquiry(makePayload()); // 建 inquiry + 2 条消息
const base = Date.parse('2026-07-13T02:12:00Z');
await prisma.chatMessage.createMany({
data: Array.from({ length: 48 }, (_, i) => ({
messageId: 'p' + i,
inquiryId: '13776617920',
sender: 'x',
direction: 'incoming' as const,
sendTime: new Date(base + (i + 1) * 1000),
content: 'c' + i,
translation: '',
})),
});
}
it('默认无 cursor 返回最新一页(asc 旧→新)+ prevCursor 指向更旧', async () => {
await seed50();
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?size=20')
.set('Authorization', 'Bearer ' + token);
expect(r1.status).toBe(200);
expect(r1.body.items.length).toBe(20);
expect(r1.body.prevCursor).toBeTruthy();
// 默认从最新:返回的 20 条应是全局最新的 20 条,且按 sendTime 升序(顶旧底新)
const ts = r1.body.items.map((m: any) => new Date(m.sendTime).getTime());
for (let i = 1; i < ts.length; i++) expect(ts[i]).toBeGreaterThanOrEqual(ts[i - 1]);
// 最新一条(全局最大 sendTime)应在 items 末尾
const prisma = getPrisma();
const maxRow = await prisma.chatMessage.findFirst({
where: { inquiryId: '13776617920' },
orderBy: { sendTime: 'desc' },
});
expect(ts[ts.length - 1]).toBe(maxRow!.sendTime.getTime());
});
it('before cursor 向上加载更旧,与已加载不重叠', async () => {
await seed50();
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?size=20')
.set('Authorization', 'Bearer ' + token);
const c = r1.body.prevCursor;
expect(c).toBeTruthy();
const r2 = await request(app)
.get(
`/api/inquiries/13776617920/messages?size=20&beforeSendTime=${encodeURIComponent(c.beforeSendTime)}&beforeMessageId=${c.beforeMessageId}`,
)
.set('Authorization', 'Bearer ' + token);
expect(r2.status).toBe(200);
expect(r2.body.items.length).toBe(20);
// 不重叠
const ids1 = new Set(r1.body.items.map((m: any) => m.messageId));
expect(r2.body.items.every((m: any) => !ids1.has(m.messageId))).toBe(true);
// r2 全部比 r1 第一条更旧
const r1First = new Date(r1.body.items[0].sendTime).getTime();
expect(r2.body.items.every((m: any) => new Date(m.sendTime).getTime() <= r1First)).toBe(true);
});
it('400 when only one cursor field provided (after or before)', async () => {
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?afterSendTime=2026-07-13T02:12:00Z')
.set('Authorization', 'Bearer ' + token);
expect(r1.status).toBe(400);
const r2 = await request(app)
.get('/api/inquiries/13776617920/messages?beforeSendTime=2026-07-13T02:12:00Z')
.set('Authorization', 'Bearer ' + token);
expect(r2.status).toBe(400);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { MySqlContainer } from '@testcontainers/mysql';
import type { StartedTestContainer } from 'testcontainers';
import { execSync } from 'node:child_process';
import { getPrisma, disconnectPrisma } from '../src/lib/prisma.js';
import { createApp } from '../src/app.js';
import { bootstrapAdmin } from '../src/services/auth.service.js';
// 注:MySqlContainer 默认 user=root password=test
let container: StartedTestContainer | null = null;
export async function setupApp() {
if (!container) {
container = await new MySqlContainer('mysql:8').withDatabase('t').start();
process.env.DATABASE_URL = `mysql://root:test@${container.getHost()}:${container.getMappedPort(3306)}/t`;
process.env.INGEST_TOKEN = 'dev-ingest-token-1234';
process.env.JWT_SECRET = 'dev-jwt-secret-1234';
process.env.ADMIN_USERNAME = 'admin';
process.env.ADMIN_PASSWORD = 'admin-password-1234';
execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
}
const prisma = getPrisma();
await bootstrapAdmin(); // 确保 admin 存在(auth 测试需要)
const app = createApp();
return { app, prisma };
}
export async function resetDb() {
const prisma = getPrisma();
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=0');
for (const t of ['chat_messages', 'inquiries', 'customers', 'users']) {
await prisma.$executeRawUnsafe(`DELETE FROM ${t}`);
}
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=1');
await bootstrapAdmin(); // resetDb 清了 users,重新 seed admin
}
export async function teardown() {
await disconnectPrisma();
await container?.stop();
container = null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, resetDb, teardown } from './setup.js';
import { makePayload } from './fixtures.js';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
async function login() {
return (
await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' })
).body.token;
}
async function pushInquiry(p: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(p);
}
describe('stats overview', () => {
it('returns total / countryTop10 / byManager / monthly', async () => {
await resetDb();
await pushInquiry(makePayload({ customer: { country: 'PE', name: 'A' } }));
await pushInquiry(
makePayload({
inquiry: { inquiryId: 'X2' },
customer: { customerId: 'c2', country: 'US', name: 'B' },
}),
);
const token = await login();
const res = await request(app)
.get('/api/stats/overview')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.body.total).toBe(2);
expect(Array.isArray(res.body.countryTop10)).toBe(true);
expect(res.body.countryTop10.some((c: any) => c.country === 'PE')).toBe(true);
expect(Array.isArray(res.body.byManager)).toBe(true);
expect(Array.isArray(res.body.monthly)).toBe(true);
// 确认 _count 已重命名为 count(前端友好)
expect(res.body.countryTop10[0]).toHaveProperty('count');
expect(res.body.countryTop10[0].count).not.toBeUndefined();
});
it('401 without jwt', async () => {
const res = await request(app).get('/api/stats/overview');
expect(res.status).toBe(401);
});
});