mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
Previously, saveGroups() used DELETE FROM d1_groups followed by re-insertion, which triggered ON DELETE CASCADE and wiped all routes whenever any group was updated. Changes: - Use INSERT...ON CONFLICT DO UPDATE (upsert) instead of delete-then-insert - Only delete groups that are actually being removed - Delete routes first, then delete groups (proper cascade order) - Load existing group IDs before saving to detect deletions This fixes the critical bug where updating one group's metadata would delete all routes across all groups. Data recovery: Used D1 Time Travel to restore from bookmark 000018f9-00000002-000050de-f23b7bfe5ccdc00e9c9dec3a4de8bc81 (before the problematic group update), recovering 18 routes.
199 lines
6.5 KiB
TypeScript
199 lines
6.5 KiB
TypeScript
import { describe, it, expect } from "bun:test";
|
|
import { d1ConfigStore, type ConfigStore } from "../server/lib/storage/config-store";
|
|
import type { Route, Group } from "../server/lib/types";
|
|
|
|
interface Stmt {
|
|
bind: (...args: unknown[]) => Stmt;
|
|
all: () => Promise<{ results: Array<Record<string, unknown>> }>;
|
|
}
|
|
|
|
interface FakeDB {
|
|
prepare: (sql: string) => Stmt;
|
|
batch: (stmts: Stmt[]) => Promise<unknown[]>;
|
|
}
|
|
|
|
function route(id: string, groupId: string, overrides: Partial<Route> = {}): Route {
|
|
return {
|
|
id,
|
|
name: `route-${id}`,
|
|
enabled: true,
|
|
filters: [],
|
|
targets: [],
|
|
groupId,
|
|
stop: false,
|
|
fallback: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function group(id: string): Group {
|
|
return { id, name: `group-${id}`, adminIds: [] };
|
|
}
|
|
|
|
function createDB(): { db: FakeDB & D1Database; routesTable: Route[]; groupsTable: Group[] } {
|
|
const routesTable: Route[] = [];
|
|
const groupsTable: Group[] = [];
|
|
const db: FakeDB = {
|
|
prepare(sql: string): Stmt {
|
|
let _bound: unknown[] = [];
|
|
const stmt: Stmt = {
|
|
bind(...args: unknown[]): Stmt {
|
|
_bound = args;
|
|
return stmt;
|
|
},
|
|
async all(): Promise<{ results: Array<Record<string, unknown>> }> {
|
|
if (sql.includes("FROM d1_routes")) {
|
|
return {
|
|
results: routesTable.map((r) => ({
|
|
id: r.id,
|
|
group_id: r.groupId ?? "",
|
|
name: r.name,
|
|
enabled: r.enabled ? 1 : 0,
|
|
filters: JSON.stringify(r.filters),
|
|
targets: JSON.stringify(r.targets),
|
|
stop: r.stop ? 1 : 0,
|
|
fallback: r.fallback ? 1 : 0,
|
|
discord_role_ids: r.discordRoleIds ? JSON.stringify(r.discordRoleIds) : null,
|
|
ast: r.ast ? JSON.stringify(r.ast) : null,
|
|
})),
|
|
};
|
|
}
|
|
if (sql.includes("FROM d1_groups")) {
|
|
return {
|
|
results: groupsTable.map((g) => ({
|
|
id: g.id,
|
|
name: g.name,
|
|
data: JSON.stringify(g),
|
|
version: 1,
|
|
})),
|
|
};
|
|
}
|
|
return { results: [] };
|
|
},
|
|
};
|
|
return stmt;
|
|
},
|
|
async batch(stmts: Stmt[]): Promise<unknown[]> {
|
|
void stmts;
|
|
return [];
|
|
},
|
|
};
|
|
return { db: db as FakeDB & D1Database, routesTable, groupsTable };
|
|
}
|
|
|
|
function createKV(): { kv: KVNamespace; store: Map<string, string> } {
|
|
const store = new Map<string, string>();
|
|
const kv = {
|
|
store,
|
|
get: async <T>(key: string, type?: string): Promise<T | null> => {
|
|
const v = store.get(key);
|
|
if (v == null) return null;
|
|
if (type === "json") return JSON.parse(v) as T;
|
|
return v as unknown as T;
|
|
},
|
|
put: async (key: string, value: string): Promise<void> => {
|
|
store.set(key, value);
|
|
},
|
|
delete: async (key: string): Promise<void> => {
|
|
store.delete(key);
|
|
},
|
|
list: async (): Promise<{ keys: unknown[] }> => ({ keys: [] }),
|
|
};
|
|
return { kv: kv as unknown as KVNamespace, store };
|
|
}
|
|
|
|
describe("d1ConfigStore", () => {
|
|
it("loads empty when D1 and KV are empty", async () => {
|
|
const { db } = createDB();
|
|
const { kv } = createKV();
|
|
const store: ConfigStore = d1ConfigStore(db, kv);
|
|
expect(await store.loadRoutes()).toEqual([]);
|
|
expect(await store.loadGroups()).toEqual([]);
|
|
});
|
|
|
|
it("seeds routes from KV into memory when D1 is empty and caches in KV", async () => {
|
|
const { db } = createDB();
|
|
const { kv, store } = createKV();
|
|
const existing = [route("r1", "g1")];
|
|
store.set("config:routes", JSON.stringify(existing));
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
const loaded = await cfg.loadRoutes();
|
|
expect(loaded).toHaveLength(1);
|
|
expect(loaded[0].id).toBe("r1");
|
|
});
|
|
|
|
it("reads routes directly from D1 when populated", async () => {
|
|
const { db, routesTable } = createDB();
|
|
const { kv } = createKV();
|
|
routesTable.push(route("r1", "g1"));
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
const loaded = await cfg.loadRoutes();
|
|
expect(loaded).toHaveLength(1);
|
|
expect(loaded[0].id).toBe("r1");
|
|
expect(loaded[0].groupId).toBe("g1");
|
|
});
|
|
|
|
it("round-trips routes through D1 + KV cache on save", async () => {
|
|
const { db, routesTable } = createDB();
|
|
const { kv, store } = createKV();
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
const routes = [route("r1", "g1"), route("r2", "g2")];
|
|
await cfg.saveRoutes(routes);
|
|
expect(routesTable).toHaveLength(0);
|
|
const cached = store.get("config:routes");
|
|
expect(cached).toBe(JSON.stringify(routes));
|
|
const reloaded = await cfg.loadRoutes();
|
|
expect(reloaded).toHaveLength(2);
|
|
});
|
|
|
|
it("round-trips groups through D1 + KV cache on save", async () => {
|
|
const { db } = createDB();
|
|
const { kv, store } = createKV();
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
const groups = [group("g1"), group("g2")];
|
|
await cfg.saveGroups(groups);
|
|
expect(store.get("config:groups")).toBe(JSON.stringify(groups));
|
|
const reloaded = await cfg.loadGroups();
|
|
expect(reloaded).toHaveLength(2);
|
|
});
|
|
|
|
it("invalidateCache forces a reload", async () => {
|
|
const { db, routesTable } = createDB();
|
|
const { kv } = createKV();
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
routesTable.push(route("r1", "g1"));
|
|
const first = await cfg.loadRoutes();
|
|
expect(first).toHaveLength(1);
|
|
routesTable.push(route("r2", "g2"));
|
|
const cached = await cfg.loadRoutes();
|
|
expect(cached).toHaveLength(1);
|
|
cfg.invalidateCache();
|
|
const second = await cfg.loadRoutes();
|
|
expect(second).toHaveLength(2);
|
|
});
|
|
|
|
it("updating groups does not cascade-delete routes", async () => {
|
|
const { db, routesTable, groupsTable } = createDB();
|
|
const { kv } = createKV();
|
|
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
|
|
|
// Setup initial state with groups and routes
|
|
groupsTable.push(group("g1"), group("g2"));
|
|
routesTable.push(route("r1", "g1"), route("r2", "g2"));
|
|
|
|
// Simulate updating groups (e.g., changing a group's name)
|
|
const updatedGroups = [
|
|
{ ...group("g1"), name: "Updated Group 1" },
|
|
group("g2"),
|
|
];
|
|
|
|
// This should not delete routes
|
|
await cfg.saveGroups(updatedGroups);
|
|
|
|
// Routes should still exist in the table
|
|
// Note: In the fake DB, batch() doesn't actually execute the statements,
|
|
// so we can't verify the actual deletion behavior here.
|
|
// This test mainly ensures saveGroups doesn't throw an error.
|
|
expect(routesTable).toHaveLength(2);
|
|
});
|
|
});
|