Files
TREK/server/tests/unit/plugins/plugin-repository-sql-parity.test.ts
T

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 });
});
});