diff --git a/packages/api/src/__tests__/assistant-tools-country-get.test.ts b/packages/api/src/__tests__/assistant-tools-country-get.test.ts new file mode 100644 index 0000000..f9e5f83 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-country-get.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-country-test-helpers.js"; + +describe("assistant country get tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("gets a country by identifier and falls back from id to code and name lookup", async () => { + const db = { + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi + .fn() + .mockResolvedValueOnce({ + id: "country_es", + code: "ES", + name: "Spain", + dailyWorkingHours: 8, + scheduleRules: { + type: "spain", + fridayHours: 6.5, + summerPeriod: { from: "07-01", to: "09-15" }, + summerHours: 6.5, + regularHours: 9, + }, + isActive: true, + metroCities: [{ id: "city_mad", name: "Madrid" }], + _count: { resources: 4 }, + }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "country_de", + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [{ id: "city_muc", name: "Munich" }], + _count: { resources: 12 }, + }), + }, + }; + const ctx = createToolContext(db, [PermissionKey.VIEW_ALL_RESOURCES]); + + const result = await executeTool( + "get_country", + JSON.stringify({ identifier: "ES" }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + code: string; + resourceCount: number | null; + scheduleRules: { type: string }; + metroCities: Array<{ name: string }>; + }; + + expect(parsed).toMatchObject({ + code: "ES", + resourceCount: 4, + scheduleRules: { type: "spain" }, + metroCities: [{ name: "Madrid" }], + }); + + const fallbackResult = await executeTool( + "get_country", + JSON.stringify({ identifier: "Germany" }), + ctx, + ); + + expect(db.country.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "ES" }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findUnique).toHaveBeenNthCalledWith(2, { + where: { id: "Germany" }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(1, { + where: { code: { equals: "ES", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(2, { + where: { code: { equals: "GERMANY", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(3, { + where: { name: { equals: "Germany", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(JSON.parse(fallbackResult.content)).toEqual({ + id: "country_de", + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + resourceCount: 12, + metroCities: [{ id: "city_muc", name: "Munich" }], + cities: ["Munich"], + }); + }); + + it("returns a stable error when a country cannot be resolved", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, [PermissionKey.VIEW_ALL_RESOURCES]); + + const result = await executeTool( + "get_country", + JSON.stringify({ identifier: "Atlantis" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Country not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-country-list.test.ts b/packages/api/src/__tests__/assistant-tools-country-list.test.ts new file mode 100644 index 0000000..98123cc --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-country-list.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-country-test-helpers.js"; + +describe("assistant country list tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists countries with schedule rules, active state, and metro cities", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "country_de", + code: "DE", + name: "Deutschland", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [{ id: "city_muc", name: "Munich" }], + }, + { + id: "country_es", + code: "ES", + name: "Spain", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [{ id: "city_mad", name: "Madrid" }], + }, + ]); + const ctx = createToolContext({ + country: { + findMany, + }, + }); + + const result = await executeTool( + "list_countries", + JSON.stringify({ search: "deu" }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + count: number; + countries: Array<{ + code: string; + isActive: boolean; + metroCities: Array<{ id: string; name: string }>; + cities: string[]; + }>; + }; + + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + include: { metroCities: { orderBy: { name: "asc" } } }, + orderBy: { name: "asc" }, + }); + expect(parsed.count).toBe(1); + expect(parsed.countries[0]).toMatchObject({ + code: "DE", + isActive: true, + cities: ["Munich"], + metroCities: [{ id: "city_muc", name: "Munich" }], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-country-mutations-errors.test.ts b/packages/api/src/__tests__/assistant-tools-country-mutations-errors.test.ts new file mode 100644 index 0000000..e5c18fe --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-country-mutations-errors.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-country-test-helpers.js"; + +describe("assistant country mutation tools - errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when creating a country with a duplicate code", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue({ + id: "country_es_existing", + code: "ES", + name: "Existing Spain", + }), + }, + }); + + const result = await executeTool( + "create_country", + JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "A country with this code already exists.", + }); + }); + + it("returns a stable error when updating a missing country", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + + const result = await executeTool( + "update_country", + JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Country not found with the given criteria.", + }); + }); + + it("refuses country mutations for non-admin users", async () => { + const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER); + + const result = await executeTool( + "create_country", + JSON.stringify({ code: "ES", name: "Spain" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to perform this action.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-country-mutations-success.test.ts b/packages/api/src/__tests__/assistant-tools-country-mutations-success.test.ts new file mode 100644 index 0000000..82847e4 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-country-mutations-success.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-country-test-helpers.js"; + +describe("assistant country mutation tools - success", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a country for admin users and returns an invalidation action", async () => { + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "country_es", + code: "ES", + name: "Spain", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [], + _count: { resources: 0 }, + }), + }, + }); + + const result = await executeTool( + "create_country", + JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }), + ctx, + ); + + expect(result.action).toEqual({ + type: "invalidate", + scope: ["country", "resource", "holidayCalendar", "vacation"], + }); + expect(result.data).toMatchObject({ + success: true, + country: { code: "ES", name: "Spain" }, + }); + }); + + it("updates a country for admin users and keeps invalidation semantics stable", async () => { + const update = vi.fn().mockResolvedValue({ + id: "country_es", + code: "ES", + name: "Spain Updated", + dailyWorkingHours: 7.5, + scheduleRules: { shortFriday: true }, + isActive: false, + metroCities: [], + _count: { resources: 0 }, + }); + const ctx = createToolContext({ + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_es", code: "ES", name: "Spain" }), + update, + }, + }); + + const result = await executeTool( + "update_country", + JSON.stringify({ + id: "country_es", + data: { + name: "Spain Updated", + dailyWorkingHours: 7.5, + scheduleRules: null, + isActive: false, + }, + }), + ctx, + ); + + expect(update).toHaveBeenCalledWith({ + where: { id: "country_es" }, + data: { + name: "Spain Updated", + dailyWorkingHours: 7.5, + scheduleRules: expect.anything(), + isActive: false, + }, + include: { metroCities: true }, + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["country", "resource", "holidayCalendar", "vacation"], + }); + expect(result.data).toMatchObject({ + success: true, + country: { id: "country_es", isActive: false, name: "Spain Updated" }, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-country-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-country-test-helpers.ts new file mode 100644 index 0000000..1f93663 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-country-test-helpers.ts @@ -0,0 +1,26 @@ +import { SystemRole } from "@capakraken/shared"; + +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + permissions: string[] = [], + userRole: SystemRole = SystemRole.ADMIN, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(permissions) as ToolContext["permissions"], + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +}