diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7e3ba3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 + +updates: + # npm dependencies (pnpm monorepo — root handles workspaces) + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - minor + - patch + labels: + - dependencies + + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions-minor-patch: + update-types: + - minor + - patch + labels: + - dependencies + - ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a35200e..b0c644c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,8 +133,14 @@ jobs: - name: Generate Prisma client run: pnpm --filter @planarchy/db exec prisma generate - - name: Run unit tests - run: pnpm test:unit + - name: Run unit tests with coverage + run: | + pnpm --filter @planarchy/engine exec vitest run --coverage + pnpm --filter @planarchy/staffing exec vitest run --coverage + pnpm --filter @planarchy/api exec vitest run --coverage + pnpm --filter @planarchy/application exec vitest run --coverage + pnpm --filter @planarchy/shared exec vitest run --coverage + pnpm --filter @planarchy/db test:unit # ────────────────────────────────────────────── # Build — depends on typecheck passing diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts new file mode 100644 index 0000000..5f15fd1 --- /dev/null +++ b/apps/web/e2e/admin.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Admin Pages", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + }); + + test("settings page loads", async ({ page }) => { + await page.goto("/admin/settings"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 }); + }); + + test("users page loads with user list", async ({ page }) => { + await page.goto("/admin/users"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 }); + // Should show a table with at least the admin user + await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=admin@planarchy.dev")).toBeVisible({ timeout: 10000 }); + }); + + test("roles page loads", async ({ page }) => { + await page.goto("/roles"); + await page.waitForLoadState("networkidle"); + await expect( + page.locator("h1").filter({ hasText: /Roles/i }), + ).toBeVisible({ timeout: 10000 }); + // Should show table or list of roles + await expect( + page.locator("table").or(page.locator("text=No roles")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("blueprints page loads", async ({ page }) => { + await page.goto("/admin/blueprints"); + await page.waitForLoadState("networkidle"); + await expect( + page.locator("h1").filter({ hasText: /Blueprints/i }), + ).toBeVisible({ timeout: 10000 }); + // Should show blueprint cards or list from seed data + await expect( + page.locator("table") + .or(page.locator("text=3D Content Production")) + .or(page.locator("text=No blueprints")), + ).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/apps/web/e2e/allocations.spec.ts b/apps/web/e2e/allocations.spec.ts new file mode 100644 index 0000000..de5320b --- /dev/null +++ b/apps/web/e2e/allocations.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Allocations", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/allocations"); + }); + + test("allocation list loads with table", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // The page title should be visible + await expect( + page.locator("h1").filter({ hasText: /Allocations|Planning/i }), + ).toBeVisible({ timeout: 10000 }); + // Table or empty state should be present + await expect( + page.locator("table").or(page.locator("text=No allocations")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("new planning entry modal opens", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const newBtn = page.locator("button", { hasText: /New Planning Entry/i }); + await expect(newBtn).toBeVisible({ timeout: 10000 }); + await newBtn.click(); + // Modal should appear with form fields + await expect( + page.locator("[role='dialog']").or(page.locator("text=Create").or(page.locator("text=Project"))), + ).toBeVisible({ timeout: 5000 }); + await page.keyboard.press("Escape"); + }); + + test("filter by status works", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Look for status filter chips or dropdown + const statusFilter = page.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i }).first(); + if ((await statusFilter.count()) > 0) { + await statusFilter.click(); + await page.waitForTimeout(300); + // After clicking a status filter, the page should still show the table + await expect( + page.locator("table").or(page.locator("text=No allocations")), + ).toBeVisible(); + } + }); + + test("column toggle panel works", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const colToggle = page.locator("button", { hasText: /Columns/i }); + if ((await colToggle.count()) > 0) { + await colToggle.click(); + await page.waitForTimeout(300); + // A panel or dropdown with column checkboxes should appear + await expect( + page.locator("input[type='checkbox']").first(), + ).toBeVisible({ timeout: 3000 }); + await page.keyboard.press("Escape"); + } + }); +}); diff --git a/apps/web/e2e/dashboard.spec.ts b/apps/web/e2e/dashboard.spec.ts new file mode 100644 index 0000000..798b5b8 --- /dev/null +++ b/apps/web/e2e/dashboard.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Dashboard", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/dashboard"); + }); + + test("loads with widget grid", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Dashboard should render the react-grid-layout container + await expect(page.locator(".react-grid-layout")).toBeVisible({ timeout: 10000 }); + }); + + test("shows at least one widget", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Each widget is wrapped in a WidgetContainer inside the grid + const widgets = page.locator(".react-grid-item"); + await expect(widgets.first()).toBeVisible({ timeout: 10000 }); + const count = await widgets.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("add widget modal opens and closes", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Look for the "Add Widget" button + const addBtn = page.locator("button", { hasText: /Add Widget/i }); + if ((await addBtn.count()) > 0) { + await addBtn.click(); + await expect(page.locator("text=Add Widget").or(page.locator("text=Available Widgets"))).toBeVisible(); + await page.keyboard.press("Escape"); + } + }); + + test("reset layout button is available", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const resetBtn = page.locator("button", { hasText: /Reset Layout/i }); + if ((await resetBtn.count()) > 0) { + await expect(resetBtn).toBeVisible(); + } + }); +}); diff --git a/apps/web/e2e/estimates.spec.ts b/apps/web/e2e/estimates.spec.ts new file mode 100644 index 0000000..b1c4e58 --- /dev/null +++ b/apps/web/e2e/estimates.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Estimates", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/estimates"); + }); + + test("estimate list loads", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect( + page.locator("h1").filter({ hasText: /Estimates/i }), + ).toBeVisible({ timeout: 10000 }); + // Should show either a table/list or an empty state + await expect( + page.locator("table").or(page.locator("text=No estimates")).or(page.locator("[data-estimate-id]")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("new estimate wizard opens with setup step", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const newBtn = page.locator("button", { hasText: /New Estimate/i }); + await expect(newBtn).toBeVisible({ timeout: 10000 }); + await newBtn.click(); + // Wizard step 1 should appear: "Setup" + await expect( + page.locator("text=Setup").or(page.locator("text=Estimate Name")), + ).toBeVisible({ timeout: 5000 }); + }); + + test("estimate wizard navigates through steps", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.locator("button", { hasText: /New Estimate/i }).click(); + + // Step 1: Setup — fill a name + await expect(page.locator("text=Setup")).toBeVisible({ timeout: 5000 }); + const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first(); + if ((await nameInput.count()) > 0) { + await nameInput.fill(`E2E Estimate ${Date.now()}`); + } + + // Click Next to go to step 2 + const nextBtn = page.locator("button", { hasText: "Next" }); + if ((await nextBtn.count()) > 0) { + await nextBtn.click(); + await page.waitForTimeout(500); + // Step 2: Assumptions + await expect( + page.locator("text=Assumptions").or(page.locator("text=Scope")), + ).toBeVisible({ timeout: 5000 }); + } + + // Close the wizard without completing + const cancelBtn = page.locator("button", { hasText: /Cancel|Close/i }).first(); + if ((await cancelBtn.count()) > 0) { + await cancelBtn.click(); + } else { + await page.keyboard.press("Escape"); + } + }); + + test("estimate wizard shows all step labels", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.locator("button", { hasText: /New Estimate/i }).click(); + + // The wizard header should show step labels + const steps = ["Setup", "Assumptions", "Scope", "Staffing", "Review"]; + for (const step of steps) { + await expect(page.locator(`text=${step}`).first()).toBeVisible({ timeout: 5000 }); + } + + await page.keyboard.press("Escape"); + }); +}); diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts new file mode 100644 index 0000000..1db8faf --- /dev/null +++ b/apps/web/e2e/navigation.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + }); + + test("main nav links navigate correctly", async ({ page }) => { + const navRoutes = [ + { label: "Dashboard", url: "/dashboard" }, + { label: "Timeline", url: "/timeline" }, + { label: "Allocations", url: "/allocations" }, + { label: "Resources", url: "/resources" }, + { label: "Projects", url: "/projects" }, + ]; + + for (const route of navRoutes) { + const navLink = page.locator(`nav a >> text="${route.label}"`).first(); + await navLink.click(); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveURL(new RegExp(route.url)); + } + }); + + test("sidebar collapse and expand works", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + // Find the collapse button (contains "Collapse" text) + const collapseBtn = page.locator("nav button", { hasText: "Collapse" }); + if ((await collapseBtn.count()) > 0) { + await collapseBtn.click(); + await page.waitForTimeout(300); + // After collapsing, the sidebar should be narrow (72px) + const nav = page.locator("nav").first(); + const box = await nav.boundingBox(); + if (box) { + expect(box.width).toBeLessThan(100); + } + + // Expand again — the button should still be visible as an icon + const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last(); + await expandBtn.click(); + await page.waitForTimeout(300); + const boxExpanded = await nav.boundingBox(); + if (boxExpanded) { + expect(boxExpanded.width).toBeGreaterThan(200); + } + } + }); + + test("preferences modal opens via sidebar button", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const prefsBtn = page.locator("nav button", { hasText: "Preferences" }); + if ((await prefsBtn.count()) > 0) { + await prefsBtn.click(); + await expect(page.locator("text=Preferences")).toBeVisible({ timeout: 5000 }); + // Should show theme/mode controls + await expect( + page.locator("text=Light").or(page.locator("text=Dark").or(page.locator("text=System"))), + ).toBeVisible(); + // Close the modal + await page.keyboard.press("Escape"); + } + }); + + test("mobile hamburger menu opens sidebar on small viewport", async ({ page }) => { + // Set a mobile viewport size + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + // The hamburger button should be visible on mobile + const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first(); + await expect(hamburgerBtn).toBeVisible({ timeout: 5000 }); + + await hamburgerBtn.click(); + await page.waitForTimeout(300); + + // Mobile sidebar overlay should appear with nav links + await expect(page.locator("text=Dashboard")).toBeVisible(); + await expect(page.locator("text=Timeline")).toBeVisible(); + + // Close button should be visible in mobile sidebar + const closeBtn = page + .locator("nav button") + .filter({ has: page.locator("svg") }) + .first(); + if ((await closeBtn.count()) > 0) { + await closeBtn.click(); + } + }); +}); diff --git a/apps/web/e2e/staffing.spec.ts b/apps/web/e2e/staffing.spec.ts new file mode 100644 index 0000000..d38049c --- /dev/null +++ b/apps/web/e2e/staffing.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Staffing", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/staffing"); + }); + + test("staffing page loads with search form", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ timeout: 10000 }); + // Search form should have skill input, date fields, and a search button + await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 }); + }); + + test("search form has default skill tags", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // The StaffingPanel pre-populates with TypeScript and React skill tags + await expect( + page.locator("text=TypeScript").or(page.locator("text=React")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("submitting search returns suggestions or empty state", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Click the search/submit button + const searchBtn = page.locator("button", { hasText: /Search|Find|Suggest/i }).first(); + await expect(searchBtn).toBeVisible({ timeout: 10000 }); + await searchBtn.click(); + await page.waitForTimeout(1000); + // After search, should show either suggestion cards or a "no suggestions" message + await expect( + page.locator("text=/Score|Availability|No suggestions|No matching/i").first() + .or(page.locator("[data-suggestion]").first()) + .or(page.locator("table").first()), + ).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/apps/web/e2e/vacations.spec.ts b/apps/web/e2e/vacations.spec.ts new file mode 100644 index 0000000..b5e7bc9 --- /dev/null +++ b/apps/web/e2e/vacations.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Vacations", () => { + test.describe("My Vacations (self-service)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/vacations/my"); + }); + + test("my vacations page loads", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1", { hasText: "My Vacations" })).toBeVisible({ timeout: 10000 }); + }); + + test("request vacation button is visible", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect( + page.locator("button", { hasText: /Request Vacation/i }), + ).toBeVisible({ timeout: 10000 }); + }); + + test("request vacation modal opens", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const reqBtn = page.locator("button", { hasText: /Request Vacation/i }); + await reqBtn.click(); + // Modal should show vacation form + await expect( + page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")), + ).toBeVisible({ timeout: 5000 }); + await page.keyboard.press("Escape"); + }); + }); + + test.describe("Vacation Management", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/vacations"); + }); + + test("vacation management page loads with tabs", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Should show List and Team Calendar tabs + await expect(page.locator("text=List").first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Team Calendar").first()).toBeVisible({ timeout: 10000 }); + }); + + test("team calendar tab renders", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.locator("button", { hasText: "Team Calendar" }).or(page.locator("text=Team Calendar")).first().click(); + await page.waitForTimeout(500); + // Calendar view should appear + await expect( + page.locator("table").or(page.locator("[data-calendar]")).or(page.locator("text=Mon").or(page.locator("text=Week"))), + ).toBeVisible({ timeout: 10000 }); + }); + + test("filter chips are visible on list tab", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Status filter options should be visible + await expect( + page.locator("button", { hasText: /All|Pending|Approved/i }).first(), + ).toBeVisible({ timeout: 10000 }); + }); + }); +}); diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts index 01ae4ff..1724811 100644 --- a/packages/api/src/__tests__/allocation-router.test.ts +++ b/packages/api/src/__tests__/allocation-router.test.ts @@ -10,6 +10,14 @@ vi.mock("../sse/event-bus.js", () => ({ emitAllocationUpdated: vi.fn(), })); +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + const createCaller = createCallerFactory(allocationRouter); function createManagerCaller(db: Record) { diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts new file mode 100644 index 0000000..a59522a --- /dev/null +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -0,0 +1,305 @@ +import { SystemRole } from "@planarchy/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@planarchy/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardOverview: vi.fn(), + getDashboardPeakTimes: vi.fn(), + getDashboardDemand: vi.fn(), + getDashboardTopValueResources: vi.fn(), + getDashboardChargeabilityOverview: vi.fn(), + }; +}); + +vi.mock("../lib/cache.js", () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../lib/anonymization.js", () => ({ + anonymizeResources: vi.fn((resources: unknown[]) => resources), + getAnonymizationDirectory: vi.fn().mockResolvedValue(null), +})); + +import { + getDashboardOverview, + getDashboardPeakTimes, + getDashboardDemand, + getDashboardTopValueResources, + getDashboardChargeabilityOverview, +} from "@planarchy/application"; +import { dashboardRouter } from "../router/dashboard.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(dashboardRouter); + +function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + }); +} + +function createControllerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "controller@example.com", name: "Controller", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_2", + systemRole: SystemRole.CONTROLLER, + permissionOverrides: null, + }, + }); +} + +function createUnauthenticatedCaller(db: Record) { + return createCaller({ + session: null, + db: db as never, + dbUser: null, + }); +} + +describe("dashboard router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── getOverview ────────────────────────────────────────────────────────── + + describe("getOverview", () => { + it("returns expected shape with resource and project counts", async () => { + const overview = { + totalResources: 42, + activeResources: 38, + totalProjects: 15, + activeProjects: 10, + draftProjects: 3, + completedProjects: 2, + totalBudgetCents: 5_000_000_00, + avgWinProbability: 78, + }; + + vi.mocked(getDashboardOverview).mockResolvedValue(overview); + + const caller = createProtectedCaller({}); + const result = await caller.getOverview(); + + expect(result).toMatchObject({ + totalResources: 42, + activeResources: 38, + totalProjects: 15, + activeProjects: 10, + }); + expect(getDashboardOverview).toHaveBeenCalledTimes(1); + }); + + it("rejects unauthenticated users", async () => { + const caller = createUnauthenticatedCaller({}); + await expect(caller.getOverview()).rejects.toThrow("Authentication required"); + }); + }); + + // ─── getPeakTimes ───────────────────────────────────────────────────────── + + describe("getPeakTimes", () => { + it("returns array of time periods", async () => { + const peakData = [ + { period: "2026-03", totalHours: 1200, entries: 15 }, + { period: "2026-04", totalHours: 1400, entries: 18 }, + ]; + + vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData); + + const caller = createProtectedCaller({}); + const result = await caller.getPeakTimes({ + startDate: "2026-03-01T00:00:00.000Z", + endDate: "2026-06-30T00:00:00.000Z", + granularity: "month", + groupBy: "project", + }); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty("period", "2026-03"); + expect(getDashboardPeakTimes).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + granularity: "month", + groupBy: "project", + }), + ); + }); + + it("passes week granularity to application layer", async () => { + vi.mocked(getDashboardPeakTimes).mockResolvedValue([]); + + const caller = createProtectedCaller({}); + await caller.getPeakTimes({ + startDate: "2026-03-01T00:00:00.000Z", + endDate: "2026-03-31T00:00:00.000Z", + granularity: "week", + groupBy: "chapter", + }); + + expect(getDashboardPeakTimes).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + granularity: "week", + groupBy: "chapter", + }), + ); + }); + }); + + // ─── getDemand ──────────────────────────────────────────────────────────── + + describe("getDemand", () => { + it("returns demand entries grouped by project", async () => { + const demandData = [ + { groupKey: "Project Alpha", totalHours: 500, headcount: 3 }, + { groupKey: "Project Beta", totalHours: 300, headcount: 2 }, + ]; + + vi.mocked(getDashboardDemand).mockResolvedValue(demandData); + + const caller = createProtectedCaller({}); + const result = await caller.getDemand({ + startDate: "2026-01-01T00:00:00.000Z", + endDate: "2026-12-31T00:00:00.000Z", + groupBy: "project", + }); + + expect(result).toHaveLength(2); + expect(getDashboardDemand).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ groupBy: "project" }), + ); + }); + + it("supports grouping by chapter", async () => { + vi.mocked(getDashboardDemand).mockResolvedValue([]); + + const caller = createProtectedCaller({}); + await caller.getDemand({ + startDate: "2026-06-01T00:00:00.000Z", + endDate: "2026-06-30T00:00:00.000Z", + groupBy: "chapter", + }); + + expect(getDashboardDemand).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ groupBy: "chapter" }), + ); + }); + }); + + // ─── getTopValueResources ───────────────────────────────────────────────── + + describe("getTopValueResources", () => { + it("returns sorted resources with default limit", async () => { + const resources = [ + { id: "res_1", displayName: "Alice", valueScore: 95 }, + { id: "res_2", displayName: "Bob", valueScore: 88 }, + ]; + + vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources); + + const caller = createProtectedCaller({}); + const result = await caller.getTopValueResources({ limit: 10 }); + + expect(result).toHaveLength(2); + expect(getDashboardTopValueResources).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ limit: 10 }), + ); + }); + + it("respects custom limit", async () => { + vi.mocked(getDashboardTopValueResources).mockResolvedValue([]); + + const caller = createProtectedCaller({}); + await caller.getTopValueResources({ limit: 5 }); + + expect(getDashboardTopValueResources).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ limit: 5 }), + ); + }); + }); + + // ─── getChargeabilityOverview ───────────────────────────────────────────── + + describe("getChargeabilityOverview", () => { + it("returns chargeability data with top and watchlist arrays", async () => { + const overview = { + avgChargeability: 72, + top: [{ id: "res_1", displayName: "Alice", chargeability: 95 }], + watchlist: [{ id: "res_3", displayName: "Carol", chargeability: 30 }], + }; + + vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue(overview); + + const caller = createControllerCaller({}); + const result = await caller.getChargeabilityOverview({ + includeProposed: false, + topN: 10, + watchlistThreshold: 15, + }); + + expect(result).toHaveProperty("top"); + expect(result).toHaveProperty("watchlist"); + expect(result.top).toHaveLength(1); + expect(result.watchlist).toHaveLength(1); + }); + + it("passes includeProposed flag to application layer", async () => { + vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ + avgChargeability: 60, + top: [], + watchlist: [], + }); + + const caller = createControllerCaller({}); + await caller.getChargeabilityOverview({ + includeProposed: true, + topN: 5, + watchlistThreshold: 20, + }); + + expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + includeProposed: true, + topN: 5, + watchlistThreshold: 20, + }), + ); + }); + + it("requires controller role — blocks USER", async () => { + const caller = createProtectedCaller({}); + await expect( + caller.getChargeabilityOverview({ + includeProposed: false, + topN: 10, + watchlistThreshold: 15, + }), + ).rejects.toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + }); +}); diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts new file mode 100644 index 0000000..cfdb0c2 --- /dev/null +++ b/packages/api/src/__tests__/project-router.test.ts @@ -0,0 +1,487 @@ +import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@planarchy/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { projectRouter } from "../router/project.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("@planarchy/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByProjectId: new Map() }), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../router/blueprint-validation.js", () => ({ + assertBlueprintDynamicFields: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../router/project-planning-read-model.js", () => ({ + loadProjectPlanningReadModel: vi.fn().mockResolvedValue({ + readModel: { assignments: [], demands: [] }, + }), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../ai-client.js", () => ({ + isDalleConfigured: vi.fn().mockReturnValue(false), + createDalleClient: vi.fn(), + parseAiError: vi.fn(), +})); + +const createCaller = createCallerFactory(projectRouter); + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "mgr_1", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }); +} + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "admin_1", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }); +} + +function createControllerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "controller@example.com", name: "Controller", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "ctrl_1", + systemRole: SystemRole.CONTROLLER, + permissionOverrides: null, + }, + }); +} + +function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + }); +} + +const sampleProject = { + id: "project_1", + shortCode: "PRJ-001", + name: "Test Project", + orderType: OrderType.CHARGEABLE, + allocationType: AllocationType.INT, + winProbability: 80, + budgetCents: 500_000_00, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-06-30"), + status: ProjectStatus.ACTIVE, + responsiblePerson: "Alice", + dynamicFields: {}, + staffingReqs: [], + blueprintId: null, + color: null, + coverImageUrl: null, + coverFocusY: 50, + utilizationCategoryId: null, + clientId: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), +}; + +describe("project router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── create ─────────────────────────────────────────────────────────────── + + describe("create", () => { + it("creates a project and returns its id", async () => { + const created = { ...sampleProject, id: "project_new" }; + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(null), // no shortCode conflict + create: vi.fn().mockResolvedValue(created), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.create({ + shortCode: "PRJ-001", + name: "Test Project", + orderType: OrderType.CHARGEABLE, + allocationType: AllocationType.INT, + winProbability: 80, + budgetCents: 500_000_00, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-06-30"), + }); + + expect(result.id).toBe("project_new"); + expect(db.project.create).toHaveBeenCalled(); + expect(db.auditLog.create).toHaveBeenCalled(); + }); + + it("throws CONFLICT when shortCode already exists", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(sampleProject), + create: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.create({ + shortCode: "PRJ-001", + name: "Duplicate", + orderType: OrderType.CHARGEABLE, + allocationType: AllocationType.INT, + budgetCents: 100_00, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-06-30"), + }), + ).rejects.toThrow( + expect.objectContaining({ code: "CONFLICT" }), + ); + }); + + it("blocks USER role from creating projects", async () => { + const db = { + project: { findUnique: vi.fn(), create: vi.fn() }, + auditLog: { create: vi.fn() }, + }; + + const caller = createProtectedCaller(db); + await expect( + caller.create({ + shortCode: "PRJ-002", + name: "Blocked", + orderType: OrderType.CHARGEABLE, + allocationType: AllocationType.INT, + budgetCents: 100_00, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-06-30"), + }), + ).rejects.toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + }); + + // ─── getById ────────────────────────────────────────────────────────────── + + describe("getById", () => { + it("returns the correct project with allocations and demands", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }), + }, + allocation: { findMany: vi.fn().mockResolvedValue([]) }, + demandRequirement: { findMany: vi.fn().mockResolvedValue([]) }, + assignment: { findMany: vi.fn().mockResolvedValue([]) }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getById({ id: "project_1" }); + + expect(result.id).toBe("project_1"); + expect(result.name).toBe("Test Project"); + expect(result).toHaveProperty("allocations"); + expect(result).toHaveProperty("demands"); + expect(result).toHaveProperty("assignments"); + }); + + it("throws NOT_FOUND when project does not exist", async () => { + const db = { + project: { findUnique: vi.fn().mockResolvedValue(null) }, + allocation: { findMany: vi.fn().mockResolvedValue([]) }, + demandRequirement: { findMany: vi.fn().mockResolvedValue([]) }, + assignment: { findMany: vi.fn().mockResolvedValue([]) }, + }; + + const caller = createProtectedCaller(db); + await expect(caller.getById({ id: "missing" })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + }); + + // ─── update ─────────────────────────────────────────────────────────────── + + describe("update", () => { + it("updates project fields", async () => { + const updated = { ...sampleProject, name: "Updated Name" }; + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(sampleProject), + update: vi.fn().mockResolvedValue(updated), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.update({ + id: "project_1", + data: { name: "Updated Name" }, + }); + + expect(result.name).toBe("Updated Name"); + expect(db.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "project_1" }, + }), + ); + expect(db.auditLog.create).toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when updating non-existent project", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.update({ id: "missing", data: { name: "X" } }), + ).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + }); + + // ─── updateStatus ───────────────────────────────────────────────────────── + + describe("updateStatus", () => { + it("transitions project status", async () => { + const updated = { ...sampleProject, status: ProjectStatus.COMPLETED }; + const db = { + project: { + update: vi.fn().mockResolvedValue(updated), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.updateStatus({ + id: "project_1", + status: ProjectStatus.COMPLETED, + }); + + expect(result.status).toBe(ProjectStatus.COMPLETED); + expect(db.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "project_1" }, + data: { status: ProjectStatus.COMPLETED }, + }), + ); + }); + }); + + // ─── batchUpdateStatus ──────────────────────────────────────────────────── + + describe("batchUpdateStatus", () => { + it("updates multiple projects and returns count", async () => { + const db = { + project: { + update: vi.fn().mockResolvedValue(sampleProject), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn((calls: unknown[]) => + Promise.all((calls as Promise[]).map(() => sampleProject)), + ), + }; + + const caller = createManagerCaller(db); + const result = await caller.batchUpdateStatus({ + ids: ["project_1", "project_2", "project_3"], + status: ProjectStatus.ON_HOLD, + }); + + expect(result.count).toBe(3); + expect(db.auditLog.create).toHaveBeenCalled(); + }); + }); + + // ─── delete ─────────────────────────────────────────────────────────────── + + describe("delete", () => { + it("deletes a project and cascades related records", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Test", shortCode: "PRJ" }), + delete: vi.fn().mockResolvedValue({}), + }, + assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const caller = createAdminCaller(db); + const result = await caller.delete({ id: "project_1" }); + + expect(result).toMatchObject({ id: "project_1", name: "Test" }); + }); + + it("throws NOT_FOUND when deleting non-existent project", async () => { + const db = { + project: { findUnique: vi.fn().mockResolvedValue(null) }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const caller = createAdminCaller(db); + await expect(caller.delete({ id: "missing" })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + + it("requires admin role — blocks manager", async () => { + const db = { + project: { findUnique: vi.fn() }, + $transaction: vi.fn(), + }; + + const caller = createManagerCaller(db); + await expect(caller.delete({ id: "project_1" })).rejects.toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + }); + + // ─── batchDelete ────────────────────────────────────────────────────────── + + describe("batchDelete", () => { + it("deletes multiple projects in a transaction", async () => { + const projects = [ + { id: "p1", name: "A", shortCode: "A1" }, + { id: "p2", name: "B", shortCode: "B1" }, + ]; + const db = { + project: { + findMany: vi.fn().mockResolvedValue(projects), + deleteMany: vi.fn().mockResolvedValue({ count: 2 }), + }, + assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const caller = createAdminCaller(db); + const result = await caller.batchDelete({ ids: ["p1", "p2"] }); + + expect(result.count).toBe(2); + }); + + it("throws NOT_FOUND when no projects match the ids", async () => { + const db = { + project: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const caller = createAdminCaller(db); + await expect(caller.batchDelete({ ids: ["missing"] })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + }); + + // ─── listWithCosts ──────────────────────────────────────────────────────── + + describe("listWithCosts", () => { + it("returns projects with cost data", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([sampleProject]), + }, + }; + + const { listAssignmentBookings } = await import("@planarchy/application"); + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + + const caller = createControllerCaller(db); + const result = await caller.listWithCosts({ limit: 20 }); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toHaveProperty("totalCostCents"); + expect(result.projects[0]).toHaveProperty("totalPersonDays"); + expect(result.projects[0]).toHaveProperty("utilizationPercent"); + }); + + it("calculates cost from assignment bookings", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([{ ...sampleProject, budgetCents: 100_000_00 }]), + }, + }; + + const { listAssignmentBookings } = await import("@planarchy/application"); + vi.mocked(listAssignmentBookings).mockResolvedValue([ + { + id: "a1", + projectId: "project_1", + resourceId: "res_1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-01-05"), + hoursPerDay: 8, + dailyCostCents: 50000, + status: "CONFIRMED", + project: { id: "project_1", name: "Test", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null }, + resource: { id: "res_1", displayName: "Alice", chapter: "CGI" }, + }, + ]); + + const caller = createControllerCaller(db); + const result = await caller.listWithCosts({ limit: 20 }); + + expect(result.projects[0]?.totalCostCents).toBeGreaterThan(0); + expect(result.projects[0]?.totalPersonDays).toBeGreaterThan(0); + }); + + it("requires controller role — blocks USER", async () => { + const db = { project: { findMany: vi.fn() } }; + + const caller = createProtectedCaller(db); + await expect(caller.listWithCosts({ limit: 20 })).rejects.toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + }); +}); diff --git a/packages/api/src/__tests__/resource-router-crud.test.ts b/packages/api/src/__tests__/resource-router-crud.test.ts new file mode 100644 index 0000000..15d73f2 --- /dev/null +++ b/packages/api/src/__tests__/resource-router-crud.test.ts @@ -0,0 +1,454 @@ +import { SystemRole } from "@planarchy/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@planarchy/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isChargeabilityActualBooking: actual.isChargeabilityActualBooking, + isChargeabilityRelevantProject: actual.isChargeabilityRelevantProject, + listAssignmentBookings: vi.fn(), + recomputeResourceValueScores: vi.fn(), + }; +}); + +vi.mock("../router/blueprint-validation.js", () => ({ + assertBlueprintDynamicFields: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../lib/anonymization.js", () => ({ + anonymizeResource: vi.fn((r: Record) => r), + anonymizeResources: vi.fn((rs: unknown[]) => rs), + anonymizeSearchMatches: vi.fn((rs: unknown[]) => rs), + getAnonymizationDirectory: vi.fn().mockResolvedValue(null), + resolveResourceIdsByDisplayedEids: vi.fn().mockResolvedValue(new Map()), +})); + +import { resourceRouter } from "../router/resource.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(resourceRouter); + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "mgr_1", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }); +} + +function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + }); +} + +const sampleResource = { + id: "res_1", + eid: "E-001", + displayName: "Alice", + email: "alice@example.com", + chapter: "CGI", + lcrCents: 5000, + ucrCents: 9000, + currency: "EUR", + chargeabilityTarget: 80, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + skills: [], + dynamicFields: {}, + blueprintId: null, + blueprint: null, + isActive: true, + createdAt: new Date("2026-03-01"), + updatedAt: new Date("2026-03-01"), + roleId: null, + portfolioUrl: null, + postalCode: null, + federalState: null, + valueScore: null, + valueScoreBreakdown: null, + valueScoreUpdatedAt: null, + userId: null, + resourceRoles: [], + areaRole: null, + countryId: null, + metroCityId: null, + orgUnitId: null, + managementLevelGroupId: null, + managementLevelId: null, + resourceType: null, + chgResponsibility: null, + rolledOff: false, + departed: false, + enterpriseId: null, + clientUnitId: null, + fte: 1, +}; + +describe("resource router CRUD", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── list ───────────────────────────────────────────────────────────────── + + describe("list", () => { + it("returns paginated results with total count", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([sampleResource]), + count: vi.fn().mockResolvedValue(1), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.list({ limit: 50 }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]?.displayName).toBe("Alice"); + expect(db.resource.findMany).toHaveBeenCalled(); + }); + + it("applies search filter", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + + const caller = createProtectedCaller(db); + await caller.list({ search: "Alice", limit: 50 }); + + expect(db.resource.findMany).toHaveBeenCalled(); + }); + }); + + // ─── getById ────────────────────────────────────────────────────────────── + + describe("getById", () => { + it("returns correct resource", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(sampleResource), + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getById({ id: "res_1" }); + + expect(result.id).toBe("res_1"); + expect(result.displayName).toBe("Alice"); + }); + + it("throws NOT_FOUND when resource does not exist", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createProtectedCaller(db); + await expect(caller.getById({ id: "missing" })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + + it("sets isOwnedByCurrentUser when userId matches", async () => { + const ownedResource = { ...sampleResource, userId: "user_1" }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(ownedResource), + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getById({ id: "res_1" }); + + expect(result.isOwnedByCurrentUser).toBe(true); + }); + }); + + // ─── create ─────────────────────────────────────────────────────────────── + + describe("create", () => { + it("creates a resource and returns it", async () => { + const created = { ...sampleResource, id: "res_new", resourceRoles: [] }; + const db = { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(created), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.create({ + eid: "E-NEW", + displayName: "New Resource", + email: "new@example.com", + lcrCents: 4000, + ucrCents: 8000, + }); + + expect(result.id).toBe("res_new"); + expect(db.resource.create).toHaveBeenCalled(); + expect(db.auditLog.create).toHaveBeenCalled(); + }); + + it("throws CONFLICT on duplicate eid or email", async () => { + const db = { + resource: { + findFirst: vi.fn().mockResolvedValue(sampleResource), + create: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.create({ + eid: "E-001", + displayName: "Duplicate", + email: "alice@example.com", + lcrCents: 5000, + ucrCents: 9000, + }), + ).rejects.toThrow( + expect.objectContaining({ code: "CONFLICT" }), + ); + }); + + it("blocks USER role from creating resources", async () => { + const db = { + resource: { findFirst: vi.fn(), create: vi.fn() }, + auditLog: { create: vi.fn() }, + }; + + const caller = createProtectedCaller(db); + await expect( + caller.create({ + eid: "E-002", + displayName: "Blocked", + email: "blocked@example.com", + lcrCents: 4000, + ucrCents: 8000, + }), + ).rejects.toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + }); + + // ─── update ─────────────────────────────────────────────────────────────── + + describe("update", () => { + it("updates resource fields", async () => { + const updated = { ...sampleResource, displayName: "Alice Updated" }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(sampleResource), + update: vi.fn().mockResolvedValue(updated), + }, + resourceRole: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.update({ + id: "res_1", + data: { displayName: "Alice Updated" }, + }); + + expect(result.displayName).toBe("Alice Updated"); + expect(db.resource.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "res_1" } }), + ); + }); + + it("throws NOT_FOUND when resource does not exist", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.update({ id: "missing", data: { displayName: "X" } }), + ).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + }); + + // ─── deactivate ─────────────────────────────────────────────────────────── + + describe("deactivate", () => { + it("sets isActive to false", async () => { + const deactivated = { ...sampleResource, isActive: false }; + const db = { + resource: { + update: vi.fn().mockResolvedValue(deactivated), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.deactivate({ id: "res_1" }); + + expect(result.isActive).toBe(false); + expect(db.resource.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "res_1" }, + data: { isActive: false }, + }), + ); + }); + }); + + // ─── getHoverCard ───────────────────────────────────────────────────────── + + describe("getHoverCard", () => { + it("returns expected shape with key fields", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + displayName: "Alice", + eid: "E-001", + email: "alice@example.com", + chapter: "CGI", + lcrCents: 5000, + ucrCents: 9000, + currency: "EUR", + chargeabilityTarget: 80, + skills: [], + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + isActive: true, + areaRole: null, + country: null, + managementLevel: null, + resourceType: null, + }), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getHoverCard({ id: "res_1" }); + + expect(result).toMatchObject({ + id: "res_1", + displayName: "Alice", + chapter: "CGI", + lcrCents: 5000, + isActive: true, + }); + }); + + it("throws NOT_FOUND for missing resource", async () => { + const db = { + resource: { findUnique: vi.fn().mockResolvedValue(null) }, + systemSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }; + + const caller = createProtectedCaller(db); + await expect(caller.getHoverCard({ id: "missing" })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + }); + + // ─── importSkillMatrix ──────────────────────────────────────────────────── + + describe("importSkillMatrix", () => { + it("imports skills for the current user resource", async () => { + const updatedResource = { + ...sampleResource, + skills: [{ skill: "Maya", proficiency: 4 }], + skillMatrixUpdatedAt: new Date(), + }; + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + email: "user@example.com", + resource: { id: "res_1" }, + }), + }, + resource: { + update: vi.fn().mockResolvedValue(updatedResource), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.importSkillMatrix({ + skills: [{ skill: "Maya", proficiency: 4 }], + }); + + expect(db.resource.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "res_1" }, + }), + ); + }); + + it("throws NOT_FOUND when user has no linked resource", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + email: "user@example.com", + resource: null, + }), + }, + }; + + const caller = createProtectedCaller(db); + await expect( + caller.importSkillMatrix({ + skills: [{ skill: "Nuke", proficiency: 3 }], + }), + ).rejects.toThrow("No resource linked to your account"); + }); + }); +}); diff --git a/packages/api/src/__tests__/timeline-allocation.test.ts b/packages/api/src/__tests__/timeline-allocation.test.ts index 4be0075..5527aab 100644 --- a/packages/api/src/__tests__/timeline-allocation.test.ts +++ b/packages/api/src/__tests__/timeline-allocation.test.ts @@ -10,6 +10,14 @@ vi.mock("../sse/event-bus.js", () => ({ emitProjectShifted: vi.fn(), })); +vi.mock("../lib/budget-alerts.js", () => ({ + checkBudgetThresholds: vi.fn(), +})); + +vi.mock("../lib/cache.js", () => ({ + invalidateDashboardCache: vi.fn(), +})); + const createCaller = createCallerFactory(timelineRouter); function createManagerCaller(db: Record) { diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index 3f824fb..f0eb8c8 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -4,5 +4,14 @@ export default defineConfig({ test: { globals: true, environment: "node", + coverage: { + provider: "v8", + thresholds: { + lines: 80, + functions: 75, + branches: 75, + statements: 80, + }, + }, }, }); diff --git a/packages/application/vitest.config.ts b/packages/application/vitest.config.ts new file mode 100644 index 0000000..f0eb8c8 --- /dev/null +++ b/packages/application/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + thresholds: { + lines: 80, + functions: 75, + branches: 75, + statements: 80, + }, + }, + }, +}); diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 3f824fb..34ab343 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -4,5 +4,14 @@ export default defineConfig({ test: { globals: true, environment: "node", + coverage: { + provider: "v8", + thresholds: { + lines: 70, + functions: 70, + branches: 65, + statements: 70, + }, + }, }, });