From 1eff658678a718a928e1eb7c314373876324ac99 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 31 May 2026 18:32:50 -0300 Subject: [PATCH] feat(quota): quotaGroups DB module (CRUD) Add src/lib/db/quotaGroups.ts with createGroup/getGroup/getGroupName/ listGroups/renameGroup/deleteGroup; deleteGroup guards against non-empty groups (throws /pools/i) and protects the 'group-demo' seed. Re-export all functions + QuotaGroup type from localDb.ts. 15 unit tests in tests/unit/quota-groups-crud.test.ts all passing. --- src/lib/db/quotaGroups.ts | 156 ++++++++++++++++++++++ src/lib/localDb.ts | 12 ++ tests/unit/quota-groups-crud.test.ts | 192 +++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 src/lib/db/quotaGroups.ts create mode 100644 tests/unit/quota-groups-crud.test.ts diff --git a/src/lib/db/quotaGroups.ts b/src/lib/db/quotaGroups.ts new file mode 100644 index 0000000000..549cebd6df --- /dev/null +++ b/src/lib/db/quotaGroups.ts @@ -0,0 +1,156 @@ +/** + * db/quotaGroups.ts — CRUD for the quota_groups table. + * + * Quota groups are first-class entities that pool multiple quota pools + * under a shared namespace. Pools reference groups via quota_pools.group_id. + * + * All SQL goes through prepared statements — never raw string interpolation. + * Import getDbInstance from ./core (Hard Rule #5). + */ + +import { getDbInstance } from "./core"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface QuotaGroup { + id: string; + name: string; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface StatementLike { + all: (...params: unknown[]) => TRow[]; + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; +} + +function getDb(): DbLike { + return getDbInstance() as unknown as DbLike; +} + +interface GroupRow { + id: string; + name: string; + created_at: string; +} + +function rowToGroup(row: GroupRow): QuotaGroup { + return { + id: row.id, + name: row.name, + createdAt: row.created_at, + }; +} + +function makeId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Create a new quota group with the given name. + * Returns the newly created QuotaGroup row. + */ +export function createGroup(name: string): QuotaGroup { + const id = makeId(); + const now = new Date().toISOString(); + + getDb() + .prepare("INSERT INTO quota_groups (id, name, created_at) VALUES (?, ?, ?)") + .run(id, name, now); + + return { id, name, createdAt: now }; +} + +/** + * Get a single quota group by id. + * Returns null if not found. + */ +export function getGroup(id: string): QuotaGroup | null { + const row = getDb() + .prepare("SELECT id, name, created_at FROM quota_groups WHERE id = ?") + .get(id); + if (!row) return null; + return rowToGroup(row); +} + +/** + * Convenience helper — returns just the group name, or null if not found. + */ +export function getGroupName(id: string): string | null { + const row = getDb() + .prepare<{ name: string }>("SELECT name FROM quota_groups WHERE id = ?") + .get(id); + return row ? row.name : null; +} + +/** + * List all quota groups, ordered by created_at ascending. + */ +export function listGroups(): QuotaGroup[] { + const rows = getDb() + .prepare("SELECT id, name, created_at FROM quota_groups ORDER BY created_at ASC") + .all(); + return rows.map(rowToGroup); +} + +/** + * Rename an existing group. + * Returns true if the row was updated, false if the group was not found. + */ +export function renameGroup(id: string, name: string): boolean { + const result = getDb() + .prepare("UPDATE quota_groups SET name = ? WHERE id = ?") + .run(name, id); + return result.changes > 0; +} + +/** + * Delete a quota group by id. + * + * Throws if: + * - The group is the protected seed 'group-demo' — it must always exist. + * - Any quota_pools row still references this group via group_id — the caller + * must reassign or delete those pools first. + * + * Returns true if a row was deleted, false if the group was not found. + */ +export function deleteGroup(id: string): boolean { + // Protect the seed group. + if (id === "group-demo") { + throw new Error( + "Cannot delete the protected seed group 'group-demo'. Reassign its pools to another group first." + ); + } + + // Guard: refuse deletion when pools still reference this group. + const refRow = getDb() + .prepare<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM quota_pools WHERE group_id = ?") + .get(id); + if (refRow && refRow.cnt > 0) { + throw new Error( + `Group '${id}' has pools; reassign or delete them first.` + ); + } + + const result = getDb() + .prepare("DELETE FROM quota_groups WHERE id = ?") + .run(id); + return result.changes > 0; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 21b6ee1776..532c7941aa 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -545,6 +545,18 @@ export { upsertAllocations, listAllocationsForApiKey, } from "./db/quotaPools"; + +export { + // Quota Groups (B2) + createGroup, + getGroup, + getGroupName, + listGroups, + renameGroup, + deleteGroup, +} from "./db/quotaGroups"; + +export type { QuotaGroup } from "./db/quotaGroups"; export { getBucket, incrementBucket, diff --git a/tests/unit/quota-groups-crud.test.ts b/tests/unit/quota-groups-crud.test.ts new file mode 100644 index 0000000000..6bbaf313a4 --- /dev/null +++ b/tests/unit/quota-groups-crud.test.ts @@ -0,0 +1,192 @@ +/** + * tests/unit/quota-groups-crud.test.ts + * + * Task B2 — quotaGroups DB module CRUD. + * + * Coverage: + * - createGroup → getGroup returns it; getGroupName returns the name. + * - listGroups includes the seeded 'group-demo' + the newly created one. + * - renameGroup changes the name (getGroup + getGroupName reflect the new name). + * - deleteGroup with NO pools → returns true. + * - deleteGroup of a group WITH a pool → throws /pools/i. + * - deleteGroup('group-demo') → throws (protected seed group). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// ── DB harness (mirrors quota-groups-migration.test.ts) ────────────────────── +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-groups-crud-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const groupsDb = await import("../../src/lib/db/quotaGroups.ts"); +const poolsDb = await import("../../src/lib/db/quotaPools.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (err: any) { + if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw err; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── B2.1: createGroup / getGroup / getGroupName ─────────────────────────────── + +test("createGroup returns a QuotaGroup with id, name, createdAt", () => { + const group = groupsDb.createGroup("My Test Group"); + + assert.ok(group.id, "id should be set"); + assert.equal(group.name, "My Test Group"); + assert.ok(group.createdAt, "createdAt should be set"); +}); + +test("getGroup returns the group after creation", () => { + const created = groupsDb.createGroup("Findable Group"); + const found = groupsDb.getGroup(created.id); + + assert.ok(found, "getGroup should find the group"); + assert.equal(found!.id, created.id); + assert.equal(found!.name, "Findable Group"); + assert.ok(found!.createdAt); +}); + +test("getGroup returns null for unknown id", () => { + const result = groupsDb.getGroup("nonexistent-id"); + assert.equal(result, null); +}); + +test("getGroupName returns the name for a known group", () => { + const group = groupsDb.createGroup("Name Test Group"); + const name = groupsDb.getGroupName(group.id); + assert.equal(name, "Name Test Group"); +}); + +test("getGroupName returns null for unknown id", () => { + const result = groupsDb.getGroupName("does-not-exist"); + assert.equal(result, null); +}); + +// ── B2.2: listGroups ────────────────────────────────────────────────────────── + +test("listGroups includes the seeded group-demo row", () => { + // Trigger migrations by getting a DB handle. + const groups = groupsDb.listGroups(); + + const demo = groups.find((g) => g.id === "group-demo"); + assert.ok(demo, "group-demo should appear in listGroups"); + assert.equal(demo!.name, "GroupDemo"); +}); + +test("listGroups includes both seeded group-demo and a newly created group", () => { + const created = groupsDb.createGroup("New List Group"); + const groups = groupsDb.listGroups(); + + const ids = groups.map((g) => g.id); + assert.ok(ids.includes("group-demo"), "group-demo should be in the list"); + assert.ok(ids.includes(created.id), "newly created group should be in the list"); + assert.ok(groups.length >= 2, "list should have at least 2 entries"); +}); + +test("listGroups is ordered by created_at ascending", () => { + // group-demo was seeded first; any new group comes after. + groupsDb.createGroup("After Demo"); + const groups = groupsDb.listGroups(); + + const demoIdx = groups.findIndex((g) => g.id === "group-demo"); + assert.ok(demoIdx >= 0, "group-demo should be in the list"); + // The seeded group-demo should appear at some position; newer groups after. + const afterIdx = groups.findIndex((g) => g.name === "After Demo"); + assert.ok(afterIdx > demoIdx, "newly created group should appear after group-demo"); +}); + +// ── B2.3: renameGroup ───────────────────────────────────────────────────────── + +test("renameGroup changes the group name", () => { + const group = groupsDb.createGroup("Original Name"); + const result = groupsDb.renameGroup(group.id, "Renamed Name"); + + assert.equal(result, true, "renameGroup should return true on success"); + + const updated = groupsDb.getGroup(group.id); + assert.ok(updated, "group should still exist after rename"); + assert.equal(updated!.name, "Renamed Name"); +}); + +test("renameGroup returns false for non-existent id", () => { + const result = groupsDb.renameGroup("ghost-id", "New Name"); + assert.equal(result, false); +}); + +test("getGroupName returns updated name after renameGroup", () => { + const group = groupsDb.createGroup("Before Rename"); + groupsDb.renameGroup(group.id, "After Rename"); + assert.equal(groupsDb.getGroupName(group.id), "After Rename"); +}); + +// ── B2.4: deleteGroup ───────────────────────────────────────────────────────── + +test("deleteGroup with no pools returns true and removes the group", () => { + const group = groupsDb.createGroup("Empty Group"); + const result = groupsDb.deleteGroup(group.id); + + assert.equal(result, true, "deleteGroup should return true"); + assert.equal(groupsDb.getGroup(group.id), null, "group should no longer exist"); +}); + +test("deleteGroup returns false for non-existent id", () => { + const result = groupsDb.deleteGroup("ghost-group"); + assert.equal(result, false); +}); + +test("deleteGroup throws when a pool still references the group", () => { + const group = groupsDb.createGroup("Group With Pool"); + + // Create a pool referencing this group. + poolsDb.createPool({ + connectionId: "conn-in-group", + name: "Blocking Pool", + groupId: group.id, + }); + + assert.throws( + () => groupsDb.deleteGroup(group.id), + /pools/i, + "deleteGroup should throw a message mentioning pools" + ); + + // Group should still exist. + const stillThere = groupsDb.getGroup(group.id); + assert.ok(stillThere, "group should still exist after failed delete"); +}); + +test("deleteGroup('group-demo') throws (protected seed group)", () => { + assert.throws( + () => groupsDb.deleteGroup("group-demo"), + /group-demo/i, + "deleteGroup should throw for the protected group-demo id" + ); +});