import type { SurrealAdapter } from "@suaveplan/surrealdb"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { CONTACTS_API_CODES, ContactsApiError, } from "../error-codes/error-codes.js"; import { SurrealGroupRepository } from "./group-repository.js"; interface RecordedCall { sql: string; params: readonly unknown[] | undefined; } function fakeAdapter(handler: (call: RecordedCall) => unknown): { adapter: SurrealAdapter; calls: RecordedCall[]; } { const calls: RecordedCall[] = []; const adapter = { async query(sql: string, params?: readonly unknown[]) { const call = { sql, params }; calls.push(call); return handler(call); }, } as unknown as SurrealAdapter; return { adapter, calls }; } function row(overrides: Partial> = {}) { return { id: "contact_group:g1", tenantId: "t", name: "g", description: null, parentId: null, path: "g1", depth: 0, metadata: {}, contactCount: 0, totalContactCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe("SurrealGroupRepository.createGroup", () => { it("createGroup writes a root group when no parent supplied", { timeout: 5000, }, async () => { const { adapter, calls } = fakeAdapter((call) => { if (call.sql.startsWith("CREATE")) return [row()]; if (call.sql.startsWith("UPDATE")) return [row()]; return []; }); const repo = new SurrealGroupRepository(adapter); const g = await repo.createGroup({ tenantId: "t", name: "g" }); expect(g.depth).toBe(0); expect(g.parentId).toBeNull(); expect(calls.length).toBe(2); }); it("createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth", { timeout: 5000, }, async () => { const parent = row({ id: "contact_group:p", depth: 9, path: "p" }); const { adapter } = fakeAdapter(() => [parent]); const repo = new SurrealGroupRepository(adapter, { maxGroupDepth: 10, enforceGroupDepth: true, }); await expect( repo.createGroup({ tenantId: "t", name: "deep", parentId: "contact_group:p", }) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED, }); }); it("createGroup permits unlimited depth when enforceGroupDepth is false", { timeout: 5000, }, async () => { const parent = row({ id: "contact_group:p", depth: 100, path: "p" }); const created = row({ id: "contact_group:child", parentId: "contact_group:p", depth: 101, path: "p/temp_group_1", }); const { adapter } = fakeAdapter((call) => { if (call.sql.includes("SELECT * FROM type::record")) return [parent]; if (call.sql.startsWith("CREATE")) return [created]; if (call.sql.startsWith("UPDATE")) return [created]; return []; }); const repo = new SurrealGroupRepository(adapter, { maxGroupDepth: 10, enforceGroupDepth: false, }); const g = await repo.createGroup({ tenantId: "t", name: "x", parentId: "contact_group:p", }); expect(g.depth).toBe(101); }); it("createGroup throws NOT_FOUND when parent missing", { timeout: 5000, }, async () => { const { adapter } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter); await expect( repo.createGroup({ tenantId: "t", name: "x", parentId: "contact_group:missing", }) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND }); }); it("createGroup wraps adapter errors in CREATE_FAILED", { timeout: 5000, }, async () => { const adapter = { async query() { throw new Error("boom"); }, } as unknown as SurrealAdapter; const repo = new SurrealGroupRepository(adapter); await expect( repo.createGroup({ tenantId: "t", name: "x" }) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED }); }); }); describe("SurrealGroupRepository CRUD + list", () => { it("getById returns null when no row", { timeout: 5000 }, async () => { const { adapter } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter); expect(await repo.getById("contact_group:x")).toBeNull(); }); it("update wraps adapter errors in UPDATE_FAILED", { timeout: 5000, }, async () => { const adapter = { async query() { throw new Error("boom"); }, } as unknown as SurrealAdapter; const repo = new SurrealGroupRepository(adapter); await expect( repo.update("contact_group:x", { name: "y" }) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.UPDATE_FAILED }); }); it("delete wraps adapter errors in DELETE_FAILED", { timeout: 5000, }, async () => { const adapter = { async query() { throw new Error("boom"); }, } as unknown as SurrealAdapter; const repo = new SurrealGroupRepository(adapter); await expect(repo.delete("contact_group:x")).rejects.toMatchObject({ code: CONTACTS_API_CODES.DELETE_FAILED, }); }); it("create accepts a pre-built Group", { timeout: 5000 }, async () => { const r = row({ description: "d" }); const { adapter } = fakeAdapter(() => [r]); const repo = new SurrealGroupRepository(adapter); const out = await repo.create({ id: "contact_group:g1", tenantId: "t", name: "g", description: "d", parentId: null, path: "g1", depth: 0, metadata: {}, contactCount: 0, totalContactCount: 0, createdAt: new Date(), updatedAt: new Date(), }); expect(out.id).toBe("contact_group:g1"); expect(out.description).toBe("d"); }); it("create wraps adapter errors in CREATE_FAILED", { timeout: 5000, }, async () => { const adapter = { async query() { throw new Error("boom"); }, } as unknown as SurrealAdapter; const repo = new SurrealGroupRepository(adapter); await expect( repo.create({ id: "contact_group:g1", tenantId: "t", name: "g", parentId: null, path: "g1", depth: 0, metadata: {}, contactCount: 0, totalContactCount: 0, createdAt: new Date(), updatedAt: new Date(), }) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED }); }); it("list applies parentId + search filters and returns mapped rows", { timeout: 5000, }, async () => { const { adapter, calls } = fakeAdapter((call) => { if (call.sql.startsWith("SELECT count")) return [{ count: 1 }]; return [row()]; }); const repo = new SurrealGroupRepository(adapter); const res = await repo.list({ tenantId: "t", parentId: "contact_group:root", search: "x", limit: 5, offset: 0, }); expect(res.total).toBe(1); expect(res.groups[0]?.id).toBe("contact_group:g1"); const last = calls[calls.length - 1]!; expect(last.sql).toContain("parentId = $p1"); expect(last.sql).toContain("ORDER BY name ASC"); }); it("getChildren returns rows", { timeout: 5000 }, async () => { const { adapter } = fakeAdapter(() => [row()]); const repo = new SurrealGroupRepository(adapter); expect((await repo.getChildren("contact_group:root")).length).toBe(1); }); it("getDescendants returns rows", { timeout: 5000 }, async () => { const { adapter } = fakeAdapter(() => [row()]); const repo = new SurrealGroupRepository(adapter); expect((await repo.getDescendants("root")).length).toBe(1); }); it("getRoots returns rows", { timeout: 5000 }, async () => { const { adapter } = fakeAdapter(() => [row()]); const repo = new SurrealGroupRepository(adapter); expect((await repo.getRoots("t")).length).toBe(1); }); it("findByIds short-circuits empty input", { timeout: 5000 }, async () => { const { adapter, calls } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter); expect(await repo.findByIds([])).toEqual([]); expect(calls).toHaveLength(0); }); it("findByIds returns mapped rows", { timeout: 5000 }, async () => { const { adapter } = fakeAdapter(() => [row()]); const repo = new SurrealGroupRepository(adapter); expect((await repo.findByIds(["g1"]))[0]?.id).toBe("contact_group:g1"); }); it("generateId yields monotonic ids by default", { timeout: 5000 }, () => { const { adapter } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter); expect(repo.generateId()).toBe("group_1"); expect(repo.generateId()).toBe("group_2"); }); it("generateId honours injected generator", { timeout: 5000 }, () => { const { adapter } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter, { idGenerator: () => "g-x", }); expect(repo.generateId()).toBe("g-x"); }); }); describe("SurrealGroupRepository.moveGroup", () => { it("moveGroup raises GROUP_DEPTH_EXCEEDED when over cap", { timeout: 5000, }, async () => { const group = row({ id: "contact_group:g", path: "g", depth: 0 }); const parent = row({ id: "contact_group:p", depth: 10, path: "p" }); const { adapter } = fakeAdapter((call) => { if (call.params?.[0] === "g") return [group]; if (call.params?.[0] === "p") return [parent]; return []; }); const repo = new SurrealGroupRepository(adapter, { maxGroupDepth: 5, enforceGroupDepth: true, }); await expect( repo.moveGroup("contact_group:g", "contact_group:p", "t") ).rejects.toBeInstanceOf(ContactsApiError); }); it("moveGroup to root resets depth to 0", { timeout: 5000 }, async () => { const group = row({ id: "contact_group:g", path: "p/g", depth: 1, parentId: "contact_group:p", }); const updated = row({ id: "contact_group:g", path: "contact_group:g", depth: 0, parentId: null, }); const { adapter } = fakeAdapter((call) => { if (call.sql.includes("SELECT")) return [group]; if (call.sql.includes("UPDATE")) return [updated]; return []; }); const repo = new SurrealGroupRepository(adapter); const out = await repo.moveGroup("contact_group:g", null, "t"); expect(out.depth).toBe(0); expect(out.parentId).toBeNull(); }); it("moveGroup throws NOT_FOUND when group missing", { timeout: 5000, }, async () => { const { adapter } = fakeAdapter(() => []); const repo = new SurrealGroupRepository(adapter); await expect( repo.moveGroup("contact_group:missing", null, "t") ).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND }); }); it("moveGroup throws NOT_FOUND when group belongs to a different tenant", { timeout: 5000, }, async () => { const group = row({ id: "contact_group:g", tenantId: "other" }); const { adapter } = fakeAdapter(() => [group]); const repo = new SurrealGroupRepository(adapter); await expect( repo.moveGroup("contact_group:g", null, "t") ).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND }); }); it("moveGroup throws NOT_FOUND when new parent missing", { timeout: 5000, }, async () => { const group = row({ id: "contact_group:g" }); const { adapter } = fakeAdapter((call) => { if (call.params?.[0] === "g") return [group]; return []; }); const repo = new SurrealGroupRepository(adapter); await expect( repo.moveGroup("contact_group:g", "contact_group:missing", "t") ).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND }); }); });