From 79fd25e1c111fba970683a069f46a71648825190 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 01:11:48 +0800 Subject: [PATCH] =?UTF-8?q?mail=20=E6=B5=8B=E8=AF=95=E5=A4=B9=E5=85=B7:?= =?UTF-8?q?=E5=86=85=E5=AD=98=20store=20=E6=8E=92=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=E7=9C=9F=20store=20=E4=B8=A5=E6=A0=BC=E5=90=8C=E5=BA=8F,?= =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E5=A4=84=E5=B7=AE=E5=BC=82=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- apps/api/src/modules/mail/__tests__/fakes.ts | 11 ++++- .../mail/__tests__/mail-store.it.test.ts | 45 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/mail/__tests__/fakes.ts b/apps/api/src/modules/mail/__tests__/fakes.ts index d67a019..5a1063f 100644 --- a/apps/api/src/modules/mail/__tests__/fakes.ts +++ b/apps/api/src/modules/mail/__tests__/fakes.ts @@ -108,6 +108,8 @@ export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map 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; deliveries: Map } { const rows = new Map(); const deliveries = new Map(); - // 与 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 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) })); }, diff --git a/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts b/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts index 1a17506..cbb1a71 100644 --- a/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts +++ b/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts @@ -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 } | 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 () => {