Files
suaveplan/packages/commerce/contacts/api-plugin-contacts/src/repositories/group-repository.test.ts
2026-05-27 23:29:53 +01:00

372 lines
13 KiB
TypeScript

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<Record<string, unknown>> = {}) {
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 });
});
});