import { and, db, eq, inArray } from "@openstatus/db"; import { monitor, page, pageComponent, pageComponentGroup, workspace, } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { expectAuditRow, createWorkspaceFixture, makeApiKeyCtx, makeUserCtx, withTestTransaction, } from "../../../test/helpers"; import type { DrizzleTx, ServiceContext } from "../../context"; import { ConflictError, ForbiddenError, LimitExceededError, NotFoundError, } from "../../errors"; import { createPageComponent } from "../create"; import { deletePageComponent } from "../delete"; import { listPageComponents } from "../list"; import { updatePageComponent } from "../update"; import { updatePageComponentOrder } from "../update-order"; const TEST_PREFIX = "svc-page-component-test"; let teamCtx: ServiceContext; let freeCtx: ServiceContext; let testPageId: number; let teamMonitorId: number; let freeMonitorId: number; beforeAll(async () => { const team = (await createWorkspaceFixture("team")).workspace; const free = (await createWorkspaceFixture("free")).workspace; teamCtx = makeUserCtx(team, { userId: 1 }); freeCtx = makeUserCtx(free, { userId: 2 }); const pageRow = await db .insert(page) .values({ workspaceId: team.id, title: `${TEST_PREFIX}-page`, description: "test", slug: `${TEST_PREFIX}-slug`, customDomain: "", }) .returning() .get(); testPageId = pageRow.id; const teamMonitor = await db .insert(monitor) .values({ workspaceId: team.id, active: true, url: "https://example.com", name: `${TEST_PREFIX}-team-monitor`, method: "GET", periodicity: "10m", regions: "ams", }) .returning() .get(); teamMonitorId = teamMonitor.id; const freeMonitor = await db .insert(monitor) .values({ workspaceId: free.id, active: true, url: "https://example.com", name: `${TEST_PREFIX}-free-monitor`, method: "GET", periodicity: "10m", regions: "ams", }) .returning() .get(); freeMonitorId = freeMonitor.id; }); afterAll(async () => { await db .delete(pageComponent) .where(eq(pageComponent.pageId, testPageId)) .catch(() => undefined); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.pageId, testPageId)) .catch(() => undefined); await db .delete(monitor) .where(inArray(monitor.id, [teamMonitorId, freeMonitorId])) .catch(() => undefined); await db .delete(page) .where(eq(page.id, testPageId)) .catch(() => undefined); }); describe("updatePageComponentOrder", () => { test("creates monitor + static components and a group", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; await updatePageComponentOrder({ ctx, input: { pageId: testPageId, components: [ { order: 0, name: `${TEST_PREFIX}-monitor-cmp`, type: "monitor", monitorId: teamMonitorId, }, { order: 1, name: `${TEST_PREFIX}-static-cmp`, type: "static", }, ], groups: [ { order: 2, name: `${TEST_PREFIX}-group`, defaultOpen: false, components: [ { order: 0, name: `${TEST_PREFIX}-grouped-static`, type: "static", }, ], }, ], }, }); const components = await tx .select() .from(pageComponent) .where(eq(pageComponent.pageId, testPageId)) .all(); const groups = await tx .select() .from(pageComponentGroup) .where(eq(pageComponentGroup.pageId, testPageId)) .all(); expect(components).toHaveLength(3); expect(groups).toHaveLength(1); // Per-entity audit emits — assert one representative of each kind. // The service writes one row per component / group, so every id in // `components` + `groups` should appear, not a single page-level row. const monitorComponent = components.find((c) => c.type === "monitor"); expect(monitorComponent).toBeDefined(); if (!monitorComponent) throw new Error("unreachable"); await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "page_component.create", entityType: "page_component", entityId: monitorComponent.id, db: tx, }); const group = groups[0]; expect(group).toBeDefined(); if (!group) throw new Error("unreachable"); await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "page_component_group.create", entityType: "page_component_group", entityId: group.id, db: tx, }); }); }); test("rejects cross-workspace monitorId with ForbiddenError", async () => { await withTestTransaction(async (tx) => { await expect( updatePageComponentOrder({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, components: [ { order: 0, name: `${TEST_PREFIX}-cross-ws`, type: "monitor", monitorId: freeMonitorId, }, ], groups: [], }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("rejects cross-workspace pageId with ForbiddenError", async () => { await withTestTransaction(async (tx) => { await expect( updatePageComponentOrder({ ctx: { ...freeCtx, db: tx }, input: { pageId: testPageId, // team's page components: [], groups: [], }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("upserts monitor components without creating duplicates", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; // The `(pageId, monitorId)` unique constraint + `onConflictDoUpdate` // is the riskiest path in this service: a regression here would // silently insert duplicate rows on every re-invocation. Run the // service twice with the same `monitorId` — expect exactly one // row, with the second call's values winning the update. await updatePageComponentOrder({ ctx, input: { pageId: testPageId, components: [ { order: 0, name: `${TEST_PREFIX}-upsert-initial`, type: "monitor", monitorId: teamMonitorId, }, ], groups: [], }, }); await updatePageComponentOrder({ ctx, input: { pageId: testPageId, components: [ { order: 5, name: `${TEST_PREFIX}-upsert-renamed`, type: "monitor", monitorId: teamMonitorId, }, ], groups: [], }, }); const rows = await tx .select() .from(pageComponent) .where( and( eq(pageComponent.pageId, testPageId), eq(pageComponent.type, "monitor"), eq(pageComponent.monitorId, teamMonitorId), ), ) .all(); expect(rows).toHaveLength(1); expect(rows[0]?.name).toBe(`${TEST_PREFIX}-upsert-renamed`); expect(rows[0]?.order).toBe(5); }); }); }); describe("listPageComponents", () => { test("respects workspace isolation", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; const freeCtxTx = { ...freeCtx, db: tx }; // Seed at least one component on the team's page so the team-side // listing has something to return. await updatePageComponentOrder({ ctx: teamCtxTx, input: { pageId: testPageId, components: [ { order: 0, name: `${TEST_PREFIX}-list-cmp`, type: "static", }, ], groups: [], }, }); const teamResult = await listPageComponents({ ctx: teamCtxTx, input: { pageId: testPageId, order: "asc" }, }); expect(teamResult.length).toBeGreaterThan(0); const freeResult = await listPageComponents({ ctx: freeCtxTx, input: { pageId: testPageId, order: "asc" }, }); expect(freeResult).toHaveLength(0); }); }); }); describe("deletePageComponent", () => { test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( deletePageComponent({ ctx: readOnlyCtx, input: { id: 999_999_999 }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("throws NotFoundError for cross-workspace id", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; // Seed a component on the team page so the cross-workspace delete // has a real target id rather than asserting against an empty page. await updatePageComponentOrder({ ctx: teamCtxTx, input: { pageId: testPageId, components: [ { order: 0, name: `${TEST_PREFIX}-delete-target`, type: "static", }, ], groups: [], }, }); const [anyComponent] = await tx .select() .from(pageComponent) .where(eq(pageComponent.pageId, testPageId)) .all(); if (!anyComponent) { throw new Error("test setup broken: no components present"); } await expect( deletePageComponent({ ctx: { ...freeCtx, db: tx }, input: { id: anyComponent.id }, }), ).rejects.toBeInstanceOf(NotFoundError); }); }); }); describe("createPageComponent", () => { test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( createPageComponent({ ctx: readOnlyCtx, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-denied`, order: 0, }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("creates a static component and emits an audit row", async () => { await withTestTransaction(async (tx) => { const created = await createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-static`, description: "hello", order: 3, }, }); expect(created.type).toBe("static"); expect(created.monitorId).toBe(null); expect(created.name).toBe(`${TEST_PREFIX}-static`); await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "page_component.create", entityType: "page_component", entityId: created.id, db: tx, }); }); }); test("inherits the monitor name when none is supplied", async () => { await withTestTransaction(async (tx) => { const created = await createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "monitor", monitorId: teamMonitorId, order: 0, }, }); expect(created.name).toBe(`${TEST_PREFIX}-team-monitor`); }); }); test("rejects a monitor from another workspace", async () => { await withTestTransaction(async (tx) => { await expect( createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "monitor", monitorId: freeMonitorId, order: 0, }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("rejects a page from another workspace", async () => { await withTestTransaction(async (tx) => { await expect( createPageComponent({ ctx: { ...freeCtx, db: tx }, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-cross`, order: 0, }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("rejects a monitor already attached to the page", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const input = { pageId: testPageId, type: "monitor" as const, monitorId: teamMonitorId, order: 0, }; await createPageComponent({ ctx, input }); await expect(createPageComponent({ ctx, input })).rejects.toBeInstanceOf( ConflictError, ); }); }); test("enforces the workspace-wide page-components limit", async () => { await withTestTransaction(async (tx) => { // The cap counts every page in the workspace, so seed the quota on a // second page and prove the create on `testPageId` still trips it. const otherPage = await tx .insert(page) .values({ workspaceId: teamCtx.workspace.id, title: `${TEST_PREFIX}-limit-page`, description: "test", slug: `${TEST_PREFIX}-limit-slug`, customDomain: "", }) .returning() .get(); // `assertWithinLimit` re-reads the workspace row, so the override has // to land in the DB rather than only on the in-memory ctx. await tx .update(workspace) .set({ limits: JSON.stringify({ "page-components": 1 }) }) .where(eq(workspace.id, teamCtx.workspace.id)); const ctx = { ...teamCtx, db: tx }; await createPageComponent({ ctx, input: { pageId: otherPage.id, type: "static", name: `${TEST_PREFIX}-limit-1`, order: 0, }, }); await expect( createPageComponent({ ctx, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-limit-2`, order: 0, }, }), ).rejects.toBeInstanceOf(LimitExceededError); }); }); test("at quota, a bad request still reports what's actually wrong", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const input = { pageId: testPageId, type: "monitor" as const, monitorId: teamMonitorId, order: 0, }; await createPageComponent({ ctx, input }); // Quota is now spent, but neither of these would consume a slot — // "limit reached" would send the caller chasing the wrong problem. await tx .update(workspace) .set({ limits: JSON.stringify({ "page-components": 1 }) }) .where(eq(workspace.id, teamCtx.workspace.id)); await expect(createPageComponent({ ctx, input })).rejects.toBeInstanceOf( ConflictError, ); await expect( createPageComponent({ ctx, input: { ...input, monitorId: freeMonitorId }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); }); describe("updatePageComponent", () => { test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( updatePageComponent({ ctx: readOnlyCtx, input: { id: 999_999_999, name: "nope" }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); test("patches only the supplied fields and emits an audit row", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; const created = await createPageComponent({ ctx: teamCtxTx, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-before`, description: "keep me", order: 1, }, }); const updated = await updatePageComponent({ ctx: teamCtxTx, input: { id: created.id, name: `${TEST_PREFIX}-after` }, }); expect(updated.name).toBe(`${TEST_PREFIX}-after`); expect(updated.description).toBe("keep me"); expect(updated.order).toBe(1); await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "page_component.update", entityType: "page_component", entityId: created.id, db: tx, }); }); }); test("throws NotFoundError for cross-workspace id", async () => { await withTestTransaction(async (tx) => { const created = await createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-cross-update`, order: 0, }, }); await expect( updatePageComponent({ ctx: { ...freeCtx, db: tx }, input: { id: created.id, name: "nope" }, }), ).rejects.toBeInstanceOf(NotFoundError); }); }); }); describe("page component group scoping", () => { async function makeGroup(tx: DrizzleTx, pageId: number, name: string) { return tx .insert(pageComponentGroup) .values({ workspaceId: teamCtx.workspace.id, pageId, name }) .returning() .get(); } async function makeSecondPage(tx: DrizzleTx) { return tx .insert(page) .values({ workspaceId: teamCtx.workspace.id, title: `${TEST_PREFIX}-page-2`, description: "test", slug: `${TEST_PREFIX}-slug-2`, customDomain: "", }) .returning() .get(); } test("createPageComponent accepts a group on the same page", async () => { await withTestTransaction(async (tx) => { const group = await makeGroup(tx, testPageId, `${TEST_PREFIX}-grp-ok`); const created = await createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-grouped`, order: 0, groupId: group.id, }, }); expect(created.groupId).toBe(group.id); }); }); test("createPageComponent rejects a group from a sibling page", async () => { await withTestTransaction(async (tx) => { // Workspace scope alone isn't enough — a sibling page's group would // render the component under a group it isn't on. const otherPage = await makeSecondPage(tx); const foreignGroup = await makeGroup( tx, otherPage.id, `${TEST_PREFIX}-grp-foreign`, ); await expect( createPageComponent({ ctx: { ...teamCtx, db: tx }, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-bad-group`, order: 0, groupId: foreignGroup.id, }, }), ).rejects.toBeInstanceOf(NotFoundError); }); }); test("updatePageComponent rejects a group from a sibling page", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; const otherPage = await makeSecondPage(tx); const foreignGroup = await makeGroup( tx, otherPage.id, `${TEST_PREFIX}-grp-foreign-2`, ); const created = await createPageComponent({ ctx: teamCtxTx, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-regroup`, order: 0, }, }); await expect( updatePageComponent({ ctx: teamCtxTx, input: { id: created.id, groupId: foreignGroup.id }, }), ).rejects.toBeInstanceOf(NotFoundError); }); }); test("updatePageComponent clears the group with null", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; const group = await makeGroup(tx, testPageId, `${TEST_PREFIX}-grp-clear`); const created = await createPageComponent({ ctx: teamCtxTx, input: { pageId: testPageId, type: "static", name: `${TEST_PREFIX}-ungroup`, order: 0, groupId: group.id, }, }); expect(created.groupId).toBe(group.id); const updated = await updatePageComponent({ ctx: teamCtxTx, input: { id: created.id, groupId: null }, }); expect(updated.groupId).toBe(null); }); }); });