Archived
mail 测试夹具:内存 store 排序与真 store 严格同序,补两处差异注释
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
@@ -108,6 +108,8 @@ export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<str
|
||||
const r = rows.get(id);
|
||||
const now = clock.now();
|
||||
if (r === undefined || !r.enabled) return false;
|
||||
// 与真 store 的边界方向不同:真 SQL 是 `polling_until < now()`(恰好等于 now 仍挡住),
|
||||
// 这里是 `> now`(恰好等于 now 可抢)。微秒级边界、两边 now 的来源本就不同,不改行为 —— 只记差异。
|
||||
if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false;
|
||||
rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) });
|
||||
return true;
|
||||
@@ -134,7 +136,9 @@ export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<str
|
||||
export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
|
||||
const rows = new Map<string, InboundRecord>();
|
||||
const deliveries = new Map<string, DeliveryRecord>();
|
||||
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)
|
||||
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)。
|
||||
// `localeCompare` vs 真 store 的 PG `orderBy(subscriber)`(依 collation)——
|
||||
// 订阅方名都是 ASCII 标识符时两者一致,前提仅此而已。
|
||||
const of = (mailId: string): DeliveryRecord[] =>
|
||||
[...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber));
|
||||
return {
|
||||
@@ -155,7 +159,10 @@ export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecor
|
||||
async list(query) {
|
||||
return [...rows.values()]
|
||||
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
|
||||
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
|
||||
// 与真 store 同序:两级键 desc(receivedAt), desc(createdAt) —— 并列时后创建的排前。
|
||||
// 少了第二级,并列会按 Map 插入序解(= createdAt 升序),与真身**方向相反**,
|
||||
// 而 rawMessage() 的 receivedAt 默认是同一个常量,并列是常态不是例外。
|
||||
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime() || b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.slice(0, query.limit)
|
||||
.map((record) => ({ record, deliveries: of(record.id) }));
|
||||
},
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
/**
|
||||
* mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、
|
||||
* 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。
|
||||
*
|
||||
* 这组用例**共享状态、按顺序跑**:用例 2(游标)依赖用例 1 落下的 acc-a,
|
||||
* 用例 4/5(listDue / SQL-domain 同源)依赖用例 3 存的那一批账号,
|
||||
* 用例 7(投递)依赖用例 6(入站)写下的 in-1 —— 单独用 `-t` 挑一条跑会失败,这是设计如此,不是缺陷。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { startItDatabase } from '@testing';
|
||||
import type { ItDatabase } from '@testing';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts';
|
||||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||||
import { isDue } from '../internal/domain/due.ts';
|
||||
import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts';
|
||||
|
||||
const database: ItDatabase | undefined = await startItDatabase();
|
||||
/*
|
||||
* **独占一个库**:这份测试要 drop/建 `sys.mail_*` 四张表,其中 `sys.mail_account` 是**配置表**
|
||||
* (刻意不进 reset-runs)。若落在共享库上,开发者导出过 DATABASE_URL 的那一刻,
|
||||
* 一次 `pnpm gates` 就会把他真实配好的邮箱账号连同密封凭证删掉。
|
||||
* 所以照 route-coverage.it.test.ts 的先例:从 base URL 换出一个 `jcai_it_mail` 来,谁的库都不碰。
|
||||
*/
|
||||
const IT_DB = 'jcai_it_mail';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`⚠️ mail store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
let poolA: Pool | undefined;
|
||||
let poolB: Pool | undefined;
|
||||
let db: Db;
|
||||
@@ -52,6 +85,8 @@ beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||||
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||||
// 新库(jcai_it_mail)里没有 sys schema —— 迁移文件假定它已存在(开发库由 db:migrate 建过)
|
||||
await poolA.query('create schema if not exists sys');
|
||||
await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade');
|
||||
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||||
db = drizzle(poolA);
|
||||
@@ -99,7 +134,9 @@ describe.skipIf(database === undefined)('mail store(真 PG)', () => {
|
||||
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', lastPolledAt: new Date(now.getTime() - 5_000) }));
|
||||
await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false }));
|
||||
await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) }));
|
||||
expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a']);
|
||||
// 恰好到点的那一行:钉住区间比较符是 `<=` 而不是 `<`(SQL 与 domain 都该判它到期)
|
||||
await store.save(account({ id: 'acc-e', name: '戊', address: 'e@we.com', lastPolledAt: new Date(now.getTime() - 30_000) }));
|
||||
expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a', 'acc-e']);
|
||||
});
|
||||
|
||||
it('**SQL 与 domain 的到期判据同源**:listDue 的返回集合 == 用 isDue 过滤 list() 的结果', async () => {
|
||||
|
||||
Reference in New Issue
Block a user