mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|||
|
|
import {
|
||
|
|
ownsPlaywrightRedisKey,
|
||
|
|
resolvePlaywrightBackingState,
|
||
|
|
teardownPlaywrightBackingState,
|
||
|
|
} from "../playwright-backing-state.mjs";
|
||
|
|
|
||
|
|
const POSTGRES_BASE_URL = "postgres://snapotter:snapotter@127.0.0.1:5432/snapotter";
|
||
|
|
const REDIS_URL = "redis://127.0.0.1:6379/0";
|
||
|
|
|
||
|
|
function backingState(runId = "owner_run_001", scope = "main") {
|
||
|
|
return resolvePlaywrightBackingState({
|
||
|
|
postgresBaseUrl: POSTGRES_BASE_URL,
|
||
|
|
redisUrl: REDIS_URL,
|
||
|
|
runId,
|
||
|
|
scope,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("Playwright backing-state ownership", () => {
|
||
|
|
test("derives stable isolated database and BullMQ identities from the validated run and scope", () => {
|
||
|
|
const main = backingState("stable_run_001", "main");
|
||
|
|
const same = backingState("stable_run_001", "main");
|
||
|
|
const analytics = backingState("stable_run_001", "analytics-local");
|
||
|
|
|
||
|
|
expect(main).toEqual(same);
|
||
|
|
expect(main.databaseName).toMatch(/^snapotter_e2e_main_[a-f0-9]{24}$/);
|
||
|
|
expect(main.bullmqPrefix).toBe(main.databaseName);
|
||
|
|
expect(new URL(main.databaseUrl).pathname).toBe(`/${main.databaseName}`);
|
||
|
|
expect(analytics.databaseName).not.toBe(main.databaseName);
|
||
|
|
});
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
{ runId: "../shared", scope: "main" },
|
||
|
|
{ runId: "safe_run", scope: "../main" },
|
||
|
|
{ runId: "safe_run", scope: "Main" },
|
||
|
|
])("rejects unsafe ownership input before deriving targets: $runId / $scope", (input) => {
|
||
|
|
expect(() =>
|
||
|
|
resolvePlaywrightBackingState({
|
||
|
|
postgresBaseUrl: POSTGRES_BASE_URL,
|
||
|
|
redisUrl: REDIS_URL,
|
||
|
|
...input,
|
||
|
|
}),
|
||
|
|
).toThrow(/Playwright (run id|backing-state scope)/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("recognizes only exact run-owned Redis namespaces", () => {
|
||
|
|
const state = backingState();
|
||
|
|
const prefix = state.bullmqPrefix;
|
||
|
|
|
||
|
|
const owned = [
|
||
|
|
`${prefix}:analytics-gate`,
|
||
|
|
`${prefix}:terminal:job-1`,
|
||
|
|
`${prefix}:batch:job-1:progress`,
|
||
|
|
`bull:${prefix}-image:meta`,
|
||
|
|
`bull:${prefix}-media:wait`,
|
||
|
|
`bull:${prefix}-ai:events`,
|
||
|
|
`bull:${prefix}-docs:completed`,
|
||
|
|
`bull:${prefix}-system:failed`,
|
||
|
|
];
|
||
|
|
const foreign = [
|
||
|
|
prefix,
|
||
|
|
`${prefix}_sibling:terminal:job-1`,
|
||
|
|
`foreign:${prefix}:terminal:job-1`,
|
||
|
|
`bull:${prefix}_sibling-image:meta`,
|
||
|
|
`bull:foreign-${prefix}-image:meta`,
|
||
|
|
`bull:${prefix}-unknown:meta`,
|
||
|
|
"snapotter:terminal:job-1",
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const key of owned) expect(ownsPlaywrightRedisKey(state, key), key).toBe(true);
|
||
|
|
for (const key of foreign) expect(ownsPlaywrightRedisKey(state, key), key).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("deletes only the exact database and Redis keys owned by this run", async () => {
|
||
|
|
const state = backingState();
|
||
|
|
const sibling = backingState("owner_run_001_extra");
|
||
|
|
const databases = new Set([state.databaseName, sibling.databaseName, "snapotter"]);
|
||
|
|
const redisKeys = new Set([
|
||
|
|
`${state.bullmqPrefix}:terminal:owned-job`,
|
||
|
|
`bull:${state.bullmqPrefix}-ai:owned-job`,
|
||
|
|
`${sibling.bullmqPrefix}:terminal:sibling-job`,
|
||
|
|
`bull:${sibling.bullmqPrefix}-ai:sibling-job`,
|
||
|
|
`foreign:${state.bullmqPrefix}:terminal:embedded-substring`,
|
||
|
|
"unrelated:key",
|
||
|
|
]);
|
||
|
|
|
||
|
|
const result = await teardownPlaywrightBackingState(state, {
|
||
|
|
async deleteRedisKeys(_ownedState, ownsKey) {
|
||
|
|
let deleted = 0;
|
||
|
|
for (const key of [...redisKeys]) {
|
||
|
|
if (!ownsKey(key)) continue;
|
||
|
|
redisKeys.delete(key);
|
||
|
|
deleted += 1;
|
||
|
|
}
|
||
|
|
return deleted;
|
||
|
|
},
|
||
|
|
async dropDatabase(ownedState) {
|
||
|
|
databases.delete(ownedState.databaseName);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual({ deletedRedisKeys: 2 });
|
||
|
|
expect(databases).toEqual(new Set([sibling.databaseName, "snapotter"]));
|
||
|
|
expect(redisKeys).toEqual(
|
||
|
|
new Set([
|
||
|
|
`${sibling.bullmqPrefix}:terminal:sibling-job`,
|
||
|
|
`bull:${sibling.bullmqPrefix}-ai:sibling-job`,
|
||
|
|
`foreign:${state.bullmqPrefix}:terminal:embedded-substring`,
|
||
|
|
"unrelated:key",
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test.each(["databaseName", "bullmqPrefix"] as const)(
|
||
|
|
"refuses a tampered %s before either backend can be mutated",
|
||
|
|
async (field) => {
|
||
|
|
const state = backingState();
|
||
|
|
const databases = new Set([state.databaseName, "production"]);
|
||
|
|
const redisKeys = new Set([`${state.bullmqPrefix}:terminal:owned-job`, "production:key"]);
|
||
|
|
const tampered = { ...state, [field]: "production" };
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
teardownPlaywrightBackingState(tampered, {
|
||
|
|
async deleteRedisKeys(_ownedState, ownsKey) {
|
||
|
|
for (const key of [...redisKeys]) {
|
||
|
|
if (ownsKey(key)) redisKeys.delete(key);
|
||
|
|
}
|
||
|
|
return 0;
|
||
|
|
},
|
||
|
|
async dropDatabase(ownedState) {
|
||
|
|
databases.delete(ownedState.databaseName);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).rejects.toThrow(/does not match the validated run identity/);
|
||
|
|
|
||
|
|
expect(databases).toEqual(new Set([state.databaseName, "production"]));
|
||
|
|
expect(redisKeys).toEqual(
|
||
|
|
new Set([`${state.bullmqPrefix}:terminal:owned-job`, "production:key"]),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test("still attempts Redis cleanup when exact database deletion fails", async () => {
|
||
|
|
const state = backingState();
|
||
|
|
const redisKeys = new Set([`${state.bullmqPrefix}:terminal:owned-job`, "unrelated:key"]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
teardownPlaywrightBackingState(state, {
|
||
|
|
async deleteRedisKeys(_ownedState, ownsKey) {
|
||
|
|
for (const key of [...redisKeys]) {
|
||
|
|
if (ownsKey(key)) redisKeys.delete(key);
|
||
|
|
}
|
||
|
|
return 1;
|
||
|
|
},
|
||
|
|
async dropDatabase() {
|
||
|
|
throw new Error("postgres unavailable");
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).rejects.toThrow(/postgres unavailable/);
|
||
|
|
|
||
|
|
expect(redisKeys).toEqual(new Set(["unrelated:key"]));
|
||
|
|
});
|
||
|
|
});
|