Initial Commit
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user