mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
213 lines
12 KiB
TypeScript
213 lines
12 KiB
TypeScript
/**
|
|
* Real-SQL parity tests for the plugin-adjacent repository methods Plan 3j Task 5
|
|
* converted but never covered against raw SQL (task-7-review.md, must-land 4c): the
|
|
* "Reviewer's full-seed parity" fixture (`r3j-parity.test.ts`, run in-process under the
|
|
* review's own isolated `r3j-mut` export, 5/5 green there) — ported here per the fix
|
|
* brief ("donate it, never import from the scratchpad"), not merely referenced.
|
|
*
|
|
* Each assertion below compares the converted repository method's result against the
|
|
* SAME statement run directly on the raw better-sqlite3 handle — the shape that catches
|
|
* M14 (`findPublicIdentity`'s miss returning `null` instead of `undefined`), M15 (its
|
|
* key order), M16 (`sharesTripWith` always `true`, a security gate) and M17
|
|
* (`existsById` ignored) from the review's mutation table, none of which any other
|
|
* suite killed.
|
|
*/
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { Trips } from '../../../src/db/entities/Trips.entity';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import { Days } from '../../../src/db/entities/Days.entity';
|
|
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
|
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
|
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
});
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
t.clear();
|
|
});
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
function seed() {
|
|
for (let i = 1; i <= 6; i++) {
|
|
testDb
|
|
.prepare("INSERT INTO users (id, username, email, password_hash, display_name, avatar) VALUES (?, ?, ?, 'x', ?, ?)")
|
|
.run(i, 'u' + i, `u${i}@x`, i % 2 ? 'D' + i : null, i === 3 ? 'a.png' : null);
|
|
}
|
|
const trips: Array<[number, number]> = [
|
|
[1, 1],
|
|
[2, 2],
|
|
[3, 1],
|
|
[4, 5],
|
|
];
|
|
for (const [id, owner] of trips) testDb.prepare('INSERT INTO trips (id, user_id, title) VALUES (?, ?, ?)').run(id, owner, 't');
|
|
for (const [trip, user] of [
|
|
[1, 2],
|
|
[1, 3],
|
|
[2, 3],
|
|
[3, 4],
|
|
[4, 1],
|
|
]) {
|
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip, user);
|
|
}
|
|
for (let d = 1; d <= 5; d++) testDb.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (?, ?, ?)').run(10 + d, d % 2 ? 1 : 2, d);
|
|
}
|
|
|
|
describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
|
it('HR9 Trips.sharesTripWith == legacy bilateral SQL for every ordered user pair (incl. missing users)', async () => {
|
|
seed();
|
|
const repo = t.repo(Trips);
|
|
const legacy = testDb.prepare(`SELECT 1 FROM trips t
|
|
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
|
|
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
|
|
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
|
|
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
|
|
LIMIT 1`);
|
|
const diffs: string[] = [];
|
|
for (let a = 1; a <= 7; a++) {
|
|
for (let b = 1; b <= 7; b++) {
|
|
const legacyResult = !!legacy.get(a, b, a, b);
|
|
const converted = await repo.sharesTripWith(a, b);
|
|
if (legacyResult !== converted) diffs.push(`${a},${b}: legacy=${legacyResult} new=${converted}`);
|
|
}
|
|
}
|
|
expect(diffs).toEqual([]);
|
|
});
|
|
|
|
it('Trips.existsById == legacy row-exists SQL, including a miss', async () => {
|
|
seed();
|
|
const repo = t.repo(Trips);
|
|
for (const id of [1, 2, 3, 4, 99]) {
|
|
const legacy = !!testDb.prepare('SELECT 1 FROM trips WHERE id = ?').get(id);
|
|
expect(await repo.existsById(id)).toBe(legacy);
|
|
}
|
|
});
|
|
|
|
it('HR1 Users.findPublicIdentity == legacy row (full key, key order) and miss', async () => {
|
|
seed();
|
|
const repo = t.repo(Users);
|
|
for (let i = 1; i <= 7; i++) {
|
|
const legacy = testDb.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(i);
|
|
const converted = (await repo.findPublicIdentity(i)) ?? undefined;
|
|
// JSON.stringify: same keys, same order, same values — catches M15 (key order)
|
|
// as well as a value drift a plain `toEqual` on two possibly-undefined objects
|
|
// would not distinguish from M14 (miss shape) below.
|
|
expect(JSON.stringify(converted)).toBe(JSON.stringify(legacy));
|
|
}
|
|
// M14: a miss returns `null` (this repository's own convention, see
|
|
// `host-surface.rpc.ts#getUser`'s `?? undefined` docstring) — the CALLER maps
|
|
// that to `undefined` to match the legacy `better-sqlite3` `.get()` shape, which
|
|
// the `JSON.stringify` comparison above already exercises for id 7 (a miss).
|
|
expect(await repo.findPublicIdentity(999)).toBeNull();
|
|
});
|
|
|
|
it('CT1/CT2 Days.listIdsByTrip + MR9 Days.findTripId == legacy', async () => {
|
|
seed();
|
|
const repo = t.repo(Days);
|
|
for (const trip of [1, 2, 3, 99]) {
|
|
const legacy = (testDb.prepare('SELECT id FROM days WHERE trip_id = ?').all(trip) as Array<{ id: number }>).map((r) => r.id);
|
|
expect(await repo.listIdsByTrip(trip)).toEqual(legacy);
|
|
}
|
|
for (const id of [11, 12, 99]) {
|
|
const legacy = (testDb.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
|
expect(await repo.findTripId(id)).toEqual(legacy);
|
|
}
|
|
});
|
|
|
|
it('MR1-MR6 PluginEntityMetadata repository == legacy statements', async () => {
|
|
seed();
|
|
const repo = t.repo(PluginEntityMetadata);
|
|
await repo.upsertValue('p', 'trip', 1, 'b', '"1"');
|
|
await repo.upsertValue('p', 'trip', 1, 'a', '"2"');
|
|
await repo.upsertValue('q', 'trip', 1, 'a', '"x"');
|
|
const before = testDb.prepare("SELECT updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as { updated_at: string };
|
|
testDb.prepare("UPDATE plugin_entity_metadata SET updated_at='2000-01-01 00:00:00' WHERE plugin_id='p' AND key='b'").run();
|
|
await repo.upsertValue('p', 'trip', 1, 'b', '"3"');
|
|
const after = testDb.prepare("SELECT id, value, updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as {
|
|
id: number;
|
|
value: string;
|
|
updated_at: string;
|
|
};
|
|
expect(after.value).toBe('"3"'); // MR4: upsert replaces the value
|
|
expect(after.updated_at).not.toBe('2000-01-01 00:00:00'); // MR4: updated_at refreshed on conflict
|
|
expect(after.updated_at).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/);
|
|
expect(before.updated_at).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/);
|
|
expect(await repo.findValue('p', 'trip', 1, 'b')).toBe('"3"'); // MR1
|
|
expect(await repo.findValue('p', 'trip', 1, 'zz')).toBeNull(); // MR1 miss
|
|
expect(await repo.countForEntity('p', 'trip', 1)).toBe(2); // MR3
|
|
expect(await repo.listForEntity('p', 'trip', 1)).toEqual(
|
|
testDb.prepare("SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key").all('p', 'trip', 1),
|
|
); // MR5, ORDER BY key
|
|
expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(true); // MR6
|
|
expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(false); // MR6, already gone
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_entity_metadata').get()).toEqual({ c: 2 }); // 'p'/'b' + 'q'/'a' survive
|
|
});
|
|
|
|
it('HR5-HR8 PluginScheduledTasks repository == legacy statements (upsert keeps id, replaces fields)', async () => {
|
|
const repo = t.repo(PluginScheduledTasks);
|
|
await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 5, payload: '1', every_ms: null });
|
|
const id1 = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='n'").get() as { id: number }).id;
|
|
await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 });
|
|
expect(testDb.prepare('SELECT id, plugin_id, name, due_at, payload, every_ms FROM plugin_scheduled_tasks').all()).toEqual([
|
|
{ id: id1, plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 },
|
|
]); // HR7: same id, replaced fields — an upsert, not a delete+insert
|
|
expect(await repo.existsForPluginAndName('p', 'n')).toBeTruthy(); // HR5
|
|
expect(await repo.existsForPluginAndName('p', 'x')).toBeFalsy(); // HR5 miss
|
|
expect(await repo.countForPlugin('p')).toBe(1); // HR6
|
|
expect(await repo.deleteByPluginAndName('p', 'n')).toBe(true); // HR8
|
|
expect(await repo.deleteByPluginAndName('p', 'n')).toBe(false); // HR8, already gone
|
|
});
|
|
|
|
it("HR5-HR7 re-arm's claim guard: rearm only claims a row whose due_at is still <= the claimed-from time", async () => {
|
|
const repo = t.repo(PluginScheduledTasks);
|
|
const now = Date.now();
|
|
await repo.upsertTask({ plugin_id: 'p', name: 'claim', due_at: now - 1000, payload: '1', every_ms: 60000 });
|
|
const id = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='claim'").get() as { id: number }).id;
|
|
// The winning claim: due_at <= claimedFromDueAt (the row's own current due_at).
|
|
expect(await repo.rearm(id, now + 60000, now - 1000)).toBe(true);
|
|
// A second, losing claim against the SAME stale claimedFromDueAt now fails — the
|
|
// row's due_at already moved forward past it (RACE-SCHED-001/002's guard).
|
|
expect(await repo.rearm(id, now + 120000, now - 1000)).toBe(false);
|
|
const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE id = ?').get(id) as { due_at: number };
|
|
expect(row.due_at).toBe(now + 60000); // the losing rearm never wrote
|
|
// deleteById carries the identical guard shape for the one-shot branch.
|
|
expect(await repo.deleteById(id, now - 1000)).toBe(false); // stale claim, no-op
|
|
expect(await repo.deleteById(id, now + 60000)).toBe(true); // current due_at, claims + deletes
|
|
});
|
|
|
|
it('must-land 2: 130 concurrent PluginEntityMetadata.upsertValueCapped calls for the SAME (plugin, entity), 130 DIFFERENT new keys, never exceed the 100 cap', async () => {
|
|
// The REAL repository method, raced through Promise.all — not a sequential loop —
|
|
// so the exists+count+write really overlaps (task-7-review.md: live, 130 rows landed
|
|
// on a 100 cap before this fix). Red without `upsertValueCapped`'s
|
|
// `getEntityManager().transactional` wrap: the old three-separately-awaited-calls
|
|
// shape let every racer's stale `count < 100` read pass before any of their writes
|
|
// landed.
|
|
const repo = t.repo(PluginEntityMetadata);
|
|
const results = await Promise.all(
|
|
Array.from({ length: 130 }, (_, i) => repo.upsertValueCapped('capbomb', 'trip', 1, `k${i}`, String(i), 100)),
|
|
);
|
|
expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted
|
|
expect(await repo.countForEntity('capbomb', 'trip', 1)).toBe(100); // never more than 100 rows
|
|
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id='capbomb'").get()).toEqual({ c: 100 });
|
|
});
|
|
|
|
it('must-land 3: 130 concurrent PluginScheduledTasks.upsertTaskCapped calls for the SAME plugin, 130 DIFFERENT new names, never exceed the 100 cap', async () => {
|
|
const repo = t.repo(PluginScheduledTasks);
|
|
const due = Date.now() + 60_000;
|
|
const results = await Promise.all(
|
|
Array.from({ length: 130 }, (_, i) => repo.upsertTaskCapped({ plugin_id: 'schedbomb', name: `t${i}`, due_at: due, payload: '1', every_ms: null }, 100)),
|
|
);
|
|
expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted
|
|
expect(await repo.countForPlugin('schedbomb')).toBe(100); // never more than 100 rows
|
|
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id='schedbomb'").get()).toEqual({ c: 100 });
|
|
});
|
|
});
|