This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/__tests__/activity-ops-store.it.test.ts
T
xcsandClaude Fable 5 0923fbfe4d feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00

94 lines
4.9 KiB
TypeScript

/**
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
*/
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 { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { createActivityOpsStore } from '../activity-ops/store.ts';
const IT_DB = 'jcai_it_activity_ops';
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(`⚠️ activity-ops 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 dbA: Db;
let dbB: Db;
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
beforeAll(async () => {
if (database === undefined) return;
poolA = new Pool({ connectionString: database.url, max: 2 });
poolB = new Pool({ connectionString: database.url, max: 2 });
dbA = drizzle(poolA);
dbB = drizzle(poolB);
await poolA.query('create schema if not exists sys');
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
await poolA.query(readFileSync(MIGRATION, 'utf8'));
});
afterAll(async () => {
await poolA?.end();
await poolB?.end();
await database?.stop();
});
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
const a = createActivityOpsStore(dbA);
const b = createActivityOpsStore(dbB);
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
await a.bindLaunch('race-1', 't-1');
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
});
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
const a = createActivityOpsStore(dbA);
const b = createActivityOpsStore(dbB);
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
expect(won.filter(Boolean)).toHaveLength(1);
await a.bindLaunch('stale-1', 't-2');
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
});
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
const a = createActivityOpsStore(dbA);
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
});
});