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:
xcs
2026-08-26 01:11:48 +08:00
co-authored by Claude Fable 5
parent 1328aeb142
commit 79fd25e1c1
2 changed files with 50 additions and 6 deletions
+9 -2
View File
@@ -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 () => {