372 lines
13 KiB
TypeScript
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 });
|
|
});
|
|
});
|