diff --git a/e2e/fixtures/api-helpers.ts b/e2e/fixtures/api-helpers.ts index 02c1e5c..b0fc6cd 100644 --- a/e2e/fixtures/api-helpers.ts +++ b/e2e/fixtures/api-helpers.ts @@ -33,7 +33,7 @@ export async function createWorkspaceViaAPI( time_project_id?: number; } ) { - const response = await request.post(`${BASE_URL}/api/workspaces`, { + const response = await request.post(`${BASE_URL}/api/v2/workspaces`, { headers: defaultHeaders, data, }); @@ -41,19 +41,20 @@ export async function createWorkspaceViaAPI( response.ok(), `create workspace failed (${response.status()}): ${await response.text()}` ).toBeTruthy(); - return response.json(); + return (await response.json()).data; } /** * List item types via the API (global catalog). Returns the raw array. */ export async function listItemTypesViaAPI(request: APIRequestContext) { - const response = await request.get(`${BASE_URL}/api/item-types`, { + const response = await request.get(`${BASE_URL}/api/v2/item-types`, { headers: defaultHeaders, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); - return Array.isArray(body) ? body : (body.data ?? body.items ?? []); + expect(Array.isArray(body.data)).toBeTruthy(); + return body.data; } /** @@ -94,14 +95,30 @@ export async function createItemViaAPI( start_date?: string; end_date?: string; custom_field_values?: Record; + item_type_id?: number; } ) { - const response = await request.post(`${BASE_URL}/api/items`, { + const { status, priority, custom_field_values, ...fields } = data; + const payload: Record = { ...fields, workspace_id: workspaceId }; + if (custom_field_values !== undefined) payload.custom_fields = custom_field_values; + for (const [name, value, path] of [ + ['status_id', status, `/workspaces/${workspaceId}/statuses`], + ['priority_id', priority, '/priorities'], + ] as const) { + if (value === undefined) continue; + const catalog = await request.get(`${BASE_URL}/api/v2${path}`, { headers: defaultHeaders }); + expect(catalog.ok(), await catalog.text()).toBeTruthy(); + const entries = (await catalog.json()).data as Array<{ id: number; name: string }>; + const matches = entries.filter((entry) => entry.name.toLowerCase() === value.toLowerCase()); + expect(matches, `expected one exact ${name} match for ${value}`).toHaveLength(1); + payload[name] = matches[0].id; + } + const response = await request.post(`${BASE_URL}/api/v2/items`, { headers: defaultHeaders, - data: { ...data, workspace_id: workspaceId }, + data: payload, }); - expect(response.ok()).toBeTruthy(); - return response.json(); + expect(response.status(), await response.text()).toBe(201); + return (await response.json()).data; } /** @@ -320,12 +337,13 @@ export async function createTimeProjectViaAPI( * Duplicates (6), Child Of (7). */ export async function listLinkTypesViaAPI(request: APIRequestContext) { - const response = await request.get(`${BASE_URL}/api/link-types`, { + const response = await request.get(`${BASE_URL}/api/v2/link-types`, { headers: defaultHeaders, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); - return (body.data ?? body) as Array<{ + expect(Array.isArray(body.data)).toBeTruthy(); + return body.data as Array<{ id: number; name: string; active?: boolean; @@ -347,12 +365,12 @@ export async function createLinkViaAPI( target_id: number; } ) { - const response = await request.post(`${BASE_URL}/api/links`, { + const response = await request.post(`${BASE_URL}/api/v2/links`, { headers: defaultHeaders, data, }); expect(response.ok()).toBeTruthy(); - return response.json(); + return (await response.json()).data; } /** diff --git a/e2e/tests/asset-tag-editing-ui.spec.ts b/e2e/tests/asset-tag-editing-ui.spec.ts index 98253fd..79fb17a 100644 --- a/e2e/tests/asset-tag-editing-ui.spec.ts +++ b/e2e/tests/asset-tag-editing-ui.spec.ts @@ -1,24 +1,28 @@ -import { expect, test } from '../fixtures/context-path'; +import { authenticateAdminRequest } from '../fixtures/api-helpers'; +import { expect, logicalPath, test } from '../fixtures/context-path'; const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; test('asset UI creates, preserves, changes, and clears asset tags', async ({ request, page }) => { + await authenticateAdminRequest(request); const stamp = Date.now(); const originalTag = `TAG-${stamp}`; const changedTag = `TAG-${stamp}-changed`; - const setResponse = await request.post('/api/asset-sets', { + const setResponse = await request.post('/api/v2/asset-sets', { headers: SEC_FETCH, data: { name: `Tagged assets ${stamp}` }, }); - expect(setResponse.ok(), `create asset set: ${await setResponse.text()}`).toBeTruthy(); - const assetSet = await setResponse.json(); + expect(setResponse.status(), `create asset set: ${await setResponse.text()}`).toBe(201); + const assetSet = (await setResponse.json()).data; + expect(assetSet.id).toBeGreaterThan(0); - const typeResponse = await request.post(`/api/asset-sets/${assetSet.id}/types`, { + const typeResponse = await request.post(`/api/v2/asset-sets/${assetSet.id}/types`, { headers: SEC_FETCH, data: { name: `Tagged server ${stamp}` }, }); - expect(typeResponse.ok(), `create asset type: ${await typeResponse.text()}`).toBeTruthy(); + expect(typeResponse.status(), `create asset type: ${await typeResponse.text()}`).toBe(201); + expect((await typeResponse.json()).data.id).toBeGreaterThan(0); await page.goto('/assets'); await page.locator('#asset-set-select').click(); @@ -30,14 +34,16 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req await page.locator('#asset-tag-input').blur(); const createPromise = page.waitForResponse( (response) => - response.url().endsWith(`/api/asset-sets/${assetSet.id}/assets`) && + response.url().endsWith(`/api/v2/asset-sets/${assetSet.id}/assets`) && response.request().method() === 'POST' ); await page.getByTestId('asset-submit').click(); const createResponse = await createPromise; - expect(createResponse.ok()).toBeTruthy(); + expect(createResponse.status()).toBe(201); expect(createResponse.request().postDataJSON().asset_tag).toBe(originalTag); - const asset = await createResponse.json(); + const asset = (await createResponse.json()).data; + expect(asset.id).toBeGreaterThan(0); + expect(asset.asset_tag).toBe(originalTag); await page.goto(`/assets/${asset.id}`); await page.getByTestId('asset-edit').click(); @@ -45,13 +51,14 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req await page.locator('#asset-title-input').fill(`Tagged asset renamed ${stamp}`); const preservePromise = page.waitForResponse( (response) => - response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT' + response.url().endsWith(`/api/v2/assets/${asset.id}`) && + response.request().method() === 'PATCH' ); await page.getByTestId('asset-submit').click(); const preserveResponse = await preservePromise; - expect(preserveResponse.ok()).toBeTruthy(); + expect(preserveResponse.status()).toBe(200); expect(preserveResponse.request().postDataJSON().asset_tag).toBe(originalTag); - expect((await preserveResponse.json()).asset_tag).toBe(originalTag); + expect((await preserveResponse.json()).data.asset_tag).toBe(originalTag); await expect(page.getByTestId('asset-submit')).toHaveCount(0); await page.getByTestId('asset-edit').click(); @@ -59,13 +66,14 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req await page.locator('#asset-tag-input').fill(changedTag); const changePromise = page.waitForResponse( (response) => - response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT' + response.url().endsWith(`/api/v2/assets/${asset.id}`) && + response.request().method() === 'PATCH' ); await page.getByTestId('asset-submit').click(); const changeResponse = await changePromise; - expect(changeResponse.ok()).toBeTruthy(); + expect(changeResponse.status()).toBe(200); expect(changeResponse.request().postDataJSON().asset_tag).toBe(changedTag); - expect((await changeResponse.json()).asset_tag).toBe(changedTag); + expect((await changeResponse.json()).data.asset_tag).toBe(changedTag); await expect(page.getByTestId('asset-submit')).toHaveCount(0); await page.getByTestId('asset-edit').click(); @@ -74,19 +82,25 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req await expect(page.locator('#asset-tag-input')).toHaveValue(''); const clearPromise = page.waitForResponse( (response) => - response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT' + response.url().endsWith(`/api/v2/assets/${asset.id}`) && + response.request().method() === 'PATCH' ); await page.getByTestId('asset-submit').click(); const clearResponse = await clearPromise; - expect(clearResponse.ok()).toBeTruthy(); + expect(clearResponse.status()).toBe(200); expect(clearResponse.request().postDataJSON().asset_tag).toBe(''); - expect((await clearResponse.json()).asset_tag ?? '').toBe(''); + expect((await clearResponse.json()).data.asset_tag ?? '').toBe(''); - const storedResponse = await request.get(`/api/assets/${asset.id}`, { + await expect(page.getByTestId('asset-submit')).toHaveCount(0); + await page.reload(); + await page.getByTestId('asset-edit').click(); + await expect(page.locator('#asset-tag-input')).toHaveValue(''); + + const storedResponse = await request.get(`/api/v2/assets/${asset.id}`, { headers: SEC_FETCH, }); - expect(storedResponse.ok()).toBeTruthy(); - expect((await storedResponse.json()).asset_tag ?? '').toBe(''); + expect(storedResponse.status()).toBe(200); + expect((await storedResponse.json()).data.asset_tag ?? '').toBe(''); // Search is part of the browser contract, not merely an API capability. // Re-enter the set from a fresh page and prove both the matching and empty @@ -95,6 +109,8 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req await page.locator('#asset-set-select').click(); await page.locator(`#asset-set-select-option-${assetSet.id}`).click(); + await expect(page.getByTestId('asset-row')).toHaveCount(1); + let releaseMatchingResponse!: () => void; let matchingRequestStarted!: () => void; let matchingResponseReleased!: () => void; @@ -109,10 +125,13 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req }); await page.route( (url) => - url.pathname === `/api/asset-sets/${assetSet.id}/assets` && + logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` && (url.searchParams.get('ql') ?? '').includes(`renamed ${stamp}`), async (route) => { const response = await route.fetch(); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.data.map((entry: { id: number }) => entry.id)).toEqual([asset.id]); matchingRequestStarted(); await matchingRelease; await route.fulfill({ response }); @@ -120,11 +139,32 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req } ); - await page.getByTestId('asset-search').fill(`renamed ${stamp}`); - await matchingRequest; - await page.getByTestId('asset-search').fill(`no-match-${stamp}`); - await expect(page.getByTestId('asset-row')).toHaveCount(0); - releaseMatchingResponse(); + const staleResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` && + (url.searchParams.get('ql') ?? '').includes(`renamed ${stamp}`) + ); + }); + try { + await page.getByTestId('asset-search').fill(`renamed ${stamp}`); + await matchingRequest; + const emptyResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` && + (url.searchParams.get('ql') ?? '').includes(`no-match-${stamp}`) + ); + }); + await page.getByTestId('asset-search').fill(`no-match-${stamp}`); + const emptyResponse = await emptyResponsePromise; + expect(emptyResponse.status()).toBe(200); + expect((await emptyResponse.json()).data).toEqual([]); + await expect(page.getByTestId('asset-row')).toHaveCount(0); + } finally { + releaseMatchingResponse(); + } await matchingReleased; + await (await staleResponsePromise).finished(); await expect(page.getByTestId('asset-row')).toHaveCount(0); }); diff --git a/e2e/tests/item-delete-feedback.spec.ts b/e2e/tests/item-delete-feedback.spec.ts index 6738fef..0a1171e 100644 --- a/e2e/tests/item-delete-feedback.spec.ts +++ b/e2e/tests/item-delete-feedback.spec.ts @@ -1,4 +1,8 @@ -import { createItemViaAPI, createWorkspaceViaAPI } from '../fixtures/api-helpers'; +import { + createItemViaAPI, + createWorkspaceViaAPI, + listItemTypesViaAPI, +} from '../fixtures/api-helpers'; import { expect, test } from '../fixtures/errors'; test.describe('item deletion feedback', () => { @@ -34,7 +38,8 @@ test.describe('item deletion feedback', () => { await page.locator('#item-delete-confirm').click(); await expect(deleteDialog).toBeHidden({ timeout: 10_000 }); - const deletionToast = page.getByTestId('toast').first(); + const deletionToast = page.getByTestId('toast'); + await expect(deletionToast).toHaveCount(1); await expect(deletionToast).toContainText('This item was deleted.'); await expect.soft(deletionToast).toHaveAttribute('data-toast-variant', 'info'); @@ -43,5 +48,65 @@ test.describe('item deletion feedback', () => { await page.getByTestId(`board-item-${remainingItem.id}`).click(); await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('item-title-edit')).toHaveText(remainingItem.title); + await expect(deletionToast).toHaveCount(1); + }); + + test('loads the new parent after reparent-and-delete without live events', async ({ + page, + request, + }) => { + // Exercise the supported polling fallback so HTTP completion owns navigation. + await page.addInitScript(() => { + Object.defineProperty(window, 'EventSource', { value: undefined, configurable: true }); + }); + const stamp = Date.now().toString(36); + const workspace = await createWorkspaceViaAPI(request, { + name: `Reparent ${stamp}`, + key: `RP${stamp.slice(-6)}`.toUpperCase(), + }); + const types = await listItemTypesViaAPI(request); + const levels = [ + ...new Set(types.map((type: { hierarchy_level: number }) => type.hierarchy_level)), + ] + .filter((level) => level >= 0) + .sort((a, b) => a - b); + expect(levels.length).toBeGreaterThanOrEqual(2); + const typeAt = (level: number) => + types.find((type: { hierarchy_level: number }) => type.hierarchy_level === level).id; + const parent = await createItemViaAPI(request, workspace.id, { + title: `Parent ${stamp}`, + item_type_id: typeAt(levels[0]), + }); + const deleted = await createItemViaAPI(request, workspace.id, { + title: `Delete ${stamp}`, + parent_id: parent.id, + item_type_id: typeAt(levels[1]), + }); + const child = await createItemViaAPI(request, workspace.id, { + title: `Child ${stamp}`, + parent_id: deleted.id, + // Generic sub-tasks can move between these regular parent levels. + item_type_id: typeAt(-1), + }); + await page.goto(`/workspaces/${workspace.id}/items/${deleted.id}`); + await expect(page.getByTestId('item-title-edit')).toHaveText(deleted.title); + await page.getByTestId('item-detail-actions-menu').click(); + await page.getByTestId('item-delete-open').click(); + await page.getByTestId('item-delete-reparent').check(); + const reparented = page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/v2/items/${deleted.id}/reparent-children` + ); + await page.locator('#item-delete-confirm').click(); + const reparentResponse = await reparented; + expect(reparentResponse.request().method()).toBe('POST'); + expect(reparentResponse.status()).toBe(200); + await expect(page).toHaveURL(new RegExp(`/items/${parent.id}$`)); + await expect(page.getByTestId('item-title-edit')).toHaveText(parent.title); + const response = await request.get(`/api/v2/items/${child.id}`); + expect(response.status()).toBe(200); + expect((await response.json()).data.parent_id).toBe(parent.id); + await expect(page.getByTestId('toast')).toHaveCount(1); + await expect(page.getByTestId('toast')).toHaveAttribute('data-toast-variant', 'info'); }); }); diff --git a/e2e/tests/item-move-cross-workspace.spec.ts b/e2e/tests/item-move-cross-workspace.spec.ts index 4f7c213..5a86df0 100644 --- a/e2e/tests/item-move-cross-workspace.spec.ts +++ b/e2e/tests/item-move-cross-workspace.spec.ts @@ -30,7 +30,7 @@ test('moves a work item to another workspace from its detail view', async ({ pag const previewResponse = page.waitForResponse( (response) => response.request().method() === 'POST' && - new URL(response.url()).pathname.endsWith(`/api/items/${item.id}/move-workspace/preview`) + new URL(response.url()).pathname.endsWith(`/api/v2/items/${item.id}/move-workspace/preview`) ); await page.getByTestId(`item-move-workspace-option-${destination.id}`).click(); expect((await previewResponse).ok()).toBeTruthy(); @@ -44,12 +44,12 @@ test('moves a work item to another workspace from its detail view', async ({ pag const moveResponse = page.waitForResponse( (response) => response.request().method() === 'POST' && - new URL(response.url()).pathname.endsWith(`/api/items/${item.id}/move-workspace`) + new URL(response.url()).pathname.endsWith(`/api/v2/items/${item.id}/move-workspace`) ); await page.getByTestId('item-move-workspace-confirm').click(); const response = await moveResponse; expect(response.ok()).toBeTruthy(); - const result = await response.json(); + const result = (await response.json()).data; expect(result.old_key).toBe(oldKey); expect(result.new_key).toMatch(new RegExp(`^${destination.key}-\\d+$`)); diff --git a/e2e/tests/linked-test-case-permissions.spec.ts b/e2e/tests/linked-test-case-permissions.spec.ts index 0d72e54..8f8ae5e 100644 --- a/e2e/tests/linked-test-case-permissions.spec.ts +++ b/e2e/tests/linked-test-case-permissions.spec.ts @@ -57,7 +57,7 @@ test('linked test cases stay invisible without test.view and reappear after perm const workspace = await createWorkspaceViaAPI(request, generateWorkspace(stamp)); const item = await createItemViaAPI(request, workspace.id, { title: `Visible item ${stamp}` }); - const testCaseResponse = await request.post(`/api/workspaces/${workspace.id}/test-cases`, { + const testCaseResponse = await request.post(`/api/v2/workspaces/${workspace.id}/test-cases`, { headers: SEC_FETCH, data: { title: `${token} ${secondSearchTerm}`, @@ -67,7 +67,7 @@ test('linked test cases stay invisible without test.view and reappear after perm }, }); expect(testCaseResponse.status(), await testCaseResponse.text()).toBe(201); - const testCase = (await testCaseResponse.json()) as { id: number }; + const testCase = (await testCaseResponse.json()).data as { id: number }; const linkTypes = await listLinkTypesViaAPI(request); const testsLinkType = linkTypes.find((linkType) => linkType.name === 'Tests'); @@ -134,7 +134,7 @@ test('linked test cases stay invisible without test.view and reappear after perm const url = new URL(response.url()); return ( response.request().method() === 'GET' && - url.pathname === '/api/links/search' && + url.pathname === '/api/v2/links/search' && url.searchParams.get('q') === token ); }); @@ -146,7 +146,7 @@ test('linked test cases stay invisible without test.view and reappear after perm const url = new URL(response.url()); return ( response.request().method() === 'GET' && - url.pathname === '/api/links/search' && + url.pathname === '/api/v2/links/search' && url.searchParams.get('q') === secondSearchTerm ); }); diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js index de81308..ac54c44 100644 --- a/frontend/src/App.test.js +++ b/frontend/src/App.test.js @@ -1,241 +1,274 @@ -import { - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/svelte"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("./lib/router.js", async () => { - const { writable } = await import("svelte/store"); - return { - currentRoute: writable({ view: "mobile-my-work", path: "/m", params: {} }), - initRouter: vi.fn(), - isMobileRoute: vi.fn((view) => String(view).startsWith("mobile-")), - navigate: vi.fn(), - }; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./lib/router.js', async () => { + const { writable } = await import('svelte/store'); + return { + currentRoute: writable({ view: 'mobile-my-work', path: '/m', params: {} }), + initRouter: vi.fn(), + isMobileRoute: vi.fn((view) => String(view).startsWith('mobile-')), + navigate: vi.fn(), + }; }); -vi.mock("./lib/stores", async () => { - const { writable } = await import("svelte/store"); - const authStore = writable({ - isAuthenticated: false, - loading: false, - currentUser: null, - }); - authStore.init = vi.fn(); - Object.defineProperty(authStore, "currentUser", { - get: () => null, - }); - return { authStore }; +vi.mock('./lib/stores', async () => { + const { get, writable } = await import('svelte/store'); + const authStore = writable({ + isAuthenticated: false, + loading: false, + currentUser: null, + }); + authStore.init = vi.fn(); + Object.defineProperty(authStore, 'currentUser', { + get: () => get(authStore).currentUser, + }); + return { authStore }; }); -vi.mock("./lib/stores/moduleSettings.js", () => ({ - moduleSettings: { load: vi.fn(), reload: vi.fn() }, +vi.mock('./lib/stores/moduleSettings.js', () => ({ + moduleSettings: { load: vi.fn(), reload: vi.fn() }, +})); + +// App startup owns the audience and loading boundary. The shell service's +// hydration and request lifecycle have their own dedicated unit tests. +vi.mock('./lib/services/authenticatedShellUI.js', () => ({ + loadAuthenticatedShellUI: vi.fn().mockResolvedValue(true), + resetAuthenticatedShellUILoad: vi.fn(), })); -vi.mock("./lib/api.js", () => ({ - api: { - setup: { getStatus: vi.fn() }, - themes: { getActive: vi.fn() }, - }, +vi.mock('./lib/api.js', () => ({ + api: { + setup: { getStatus: vi.fn() }, + themes: { getActive: vi.fn() }, + }, })); -vi.mock("./lib/stores/theme.svelte.js", () => ({ - themeStore: { - resolvedTheme: "light", - init: vi.fn(), - setActiveTheme: vi.fn(), - }, +vi.mock('./lib/stores/theme.svelte.js', () => ({ + themeStore: { + resolvedTheme: 'light', + init: vi.fn(), + setActiveTheme: vi.fn(), + }, })); -vi.mock("./lib/stores/i18n.svelte.js", () => ({ - t: (key) => key, - i18n: { - locale: "en", - direction: "ltr", - init: vi.fn().mockResolvedValue(undefined), - setLocale: vi.fn(), - }, - SUPPORTED_LOCALES: [{ code: "en" }], +vi.mock('./lib/stores/i18n.svelte.js', () => ({ + t: (key) => key, + i18n: { + locale: 'en', + direction: 'ltr', + init: vi.fn().mockResolvedValue(undefined), + setLocale: vi.fn(), + }, + SUPPORTED_LOCALES: [{ code: 'en' }], })); -vi.mock("./lib/mobile/MobileShell.svelte", async () => ({ - default: (await import("./test-fixtures/AppReadyStub.svelte")).default, +vi.mock('./lib/mobile/MobileShell.svelte', async () => ({ + default: (await import('./test-fixtures/AppReadyStub.svelte')).default, })); -vi.mock("./lib/dialogs/LoginDialog.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/dialogs/LoginDialog.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/pages/WelcomeAssistant.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/pages/WelcomeAssistant.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/layout/Portal.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/layout/Portal.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/features/forms/PublicFormPage.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/features/forms/PublicFormPage.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/pages/SetPassword.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/pages/SetPassword.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/pages/MainApp.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/pages/MainApp.svelte', async () => ({ + default: (await import('./test-fixtures/AppReadyStub.svelte')).default, })); -vi.mock("./lib/pages/PublicBoard.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/pages/PublicBoard.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -vi.mock("./lib/features/pages/PagePrintView.svelte", async () => ({ - default: (await import("./test-fixtures/AppDialogStub.svelte")).default, +vi.mock('./lib/features/pages/PagePrintView.svelte', async () => ({ + default: (await import('./test-fixtures/AppDialogStub.svelte')).default, })); -import App from "./App.svelte"; -import { api } from "./lib/api.js"; -import { authStore } from "./lib/stores"; -import { i18n } from "./lib/stores/i18n.svelte.js"; -import { themeStore } from "./lib/stores/theme.svelte.js"; +import App from './App.svelte'; +import { api } from './lib/api.js'; +import { currentRoute } from './lib/router.js'; +import { + loadAuthenticatedShellUI, + resetAuthenticatedShellUILoad, +} from './lib/services/authenticatedShellUI.js'; +import { authStore } from './lib/stores'; +import { i18n } from './lib/stores/i18n.svelte.js'; +import { themeStore } from './lib/stores/theme.svelte.js'; const authenticatedState = { - isAuthenticated: true, - loading: false, - currentUser: { id: 1, language: "en" }, + isAuthenticated: true, + loading: false, + currentUser: { id: 1, language: 'en' }, }; -describe("App startup recovery", () => { - beforeEach(() => { - authStore.set({ - isAuthenticated: false, - loading: false, - currentUser: null, - }); - authStore.init.mockReset().mockImplementation(async () => { - authStore.set(authenticatedState); - return { status: "authenticated" }; - }); - api.setup.getStatus - .mockReset() - .mockResolvedValue({ setup_completed: true }); - api.themes.getActive.mockReset().mockResolvedValue({}); - themeStore.init.mockClear(); - themeStore.setActiveTheme.mockClear(); - i18n.init.mockReset().mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.useRealTimers(); - document.body.innerHTML = ""; - }); - - it("shows the branded loader during startup", () => { - i18n.init.mockReturnValue(new Promise(() => {})); - - render(App); - - const loader = screen.getByTestId("branded-loader"); - expect(within(loader).getByTestId("branded-loader-logo")).toHaveAttribute( - "src", - "windshift-3.svg", - ); - expect(loader).toHaveAttribute("role", "status"); - }); - - it("renders the authenticated shell without waiting for theme loading", async () => { - api.themes.getActive.mockReturnValue(new Promise(() => {})); - - render(App); - - expect(await screen.findByTestId("app-shell-ready")).toBeInTheDocument(); - expect(api.themes.getActive).toHaveBeenCalledWith({ timeout: 10_000 }); - }); - - it("loads the active theme after an interactive login", async () => { - const activeTheme = { - nav_background_color_light: "#112233", - nav_text_color_light: "#fefefe", - nav_background_color_dark: "#010203", - nav_text_color_dark: "#eeeeee", - }; - authStore.init.mockResolvedValue({ status: "unauthenticated" }); - api.themes.getActive.mockResolvedValue(activeTheme); - - render(App); - - expect(await screen.findByTestId("dialog-stub")).toBeInTheDocument(); - expect(api.themes.getActive).not.toHaveBeenCalled(); - - authStore.set(authenticatedState); - - await waitFor(() => - expect(api.themes.getActive).toHaveBeenCalledWith({ timeout: 10_000 }), - ); - expect(themeStore.setActiveTheme).toHaveBeenLastCalledWith(activeTheme); - }); - - it("ignores an authenticated theme response that arrives after logout", async () => { - const activeTheme = { - nav_background_color_light: "#112233", - nav_text_color_light: "#fefefe", - nav_background_color_dark: "#010203", - nav_text_color_dark: "#eeeeee", - }; - let resolveTheme; - const themeRequest = new Promise((resolve) => { - resolveTheme = resolve; - }); - api.themes.getActive.mockReturnValue(themeRequest); - - render(App); - - expect(await screen.findByTestId("app-shell-ready")).toBeInTheDocument(); - await waitFor(() => expect(api.themes.getActive).toHaveBeenCalledOnce()); - - authStore.set({ - isAuthenticated: false, - loading: false, - currentUser: null, - }); - await waitFor(() => - expect(themeStore.setActiveTheme).toHaveBeenLastCalledWith( - expect.objectContaining({ nav_background_color_light: "#ffffff" }), - ), - ); - - resolveTheme(activeTheme); - await themeRequest; - await Promise.resolve(); - - expect(themeStore.setActiveTheme).not.toHaveBeenCalledWith(activeTheme); - }); - - it("shows a connection error and retries bootstrap in place", async () => { - api.setup.getStatus - .mockRejectedValueOnce( - Object.assign(new Error("offline"), { code: "NETWORK_ERROR" }), - ) - .mockResolvedValueOnce({ setup_completed: true }); - - render(App); - - expect(await screen.findByTestId("startup-error")).toBeInTheDocument(); - expect(screen.getByTestId("startup-retry")).toBeInTheDocument(); - await fireEvent.click(screen.getByTestId("startup-retry")); - - await waitFor(() => - expect(screen.getByTestId("app-shell-ready")).toBeInTheDocument(), - ); - expect(api.setup.getStatus).toHaveBeenCalledTimes(2); - }); - - it("times out a stalled locale chunk instead of loading forever", async () => { - vi.useFakeTimers(); - i18n.init.mockReturnValue(new Promise(() => {})); - - render(App); - await vi.advanceTimersByTimeAsync(10_000); - - expect(screen.getByTestId("startup-error")).toBeInTheDocument(); - expect(screen.getByTestId("startup-retry")).toBeInTheDocument(); - expect(api.setup.getStatus).not.toHaveBeenCalled(); - }); +describe('App startup recovery', () => { + let originalHTMLAttributes; + beforeEach(() => { + originalHTMLAttributes = [...document.documentElement.attributes].map(({ name, value }) => [ + name, + value, + ]); + currentRoute.set({ view: 'mobile-my-work', path: '/m', params: {} }); + loadAuthenticatedShellUI.mockReset().mockResolvedValue(true); + resetAuthenticatedShellUILoad.mockClear(); + authStore.set({ + isAuthenticated: false, + loading: false, + currentUser: null, + }); + authStore.init.mockReset().mockImplementation(async () => { + authStore.set(authenticatedState); + return { status: 'authenticated' }; + }); + api.setup.getStatus.mockReset().mockResolvedValue({ setup_completed: true }); + api.themes.getActive.mockReset().mockResolvedValue({}); + themeStore.init.mockClear(); + themeStore.setActiveTheme.mockClear(); + i18n.init.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + vi.clearAllTimers(); + vi.useRealTimers(); + for (const { name } of [...document.documentElement.attributes]) + document.documentElement.removeAttribute(name); + for (const [name, value] of originalHTMLAttributes) + document.documentElement.setAttribute(name, value); + }); + + it('shows the branded loader during startup', () => { + vi.useFakeTimers(); + i18n.init.mockReturnValue(new Promise(() => {})); + + render(App); + + const loader = screen.getByTestId('branded-loader'); + expect(within(loader).getByTestId('branded-loader-logo')).toHaveAttribute( + 'src', + 'windshift-3.svg' + ); + expect(loader).toHaveAttribute('role', 'status'); + }); + + it('renders the authenticated shell without waiting for theme loading', async () => { + api.themes.getActive.mockReturnValue(new Promise(() => {})); + + render(App); + + expect(await screen.findByTestId('app-shell-ready')).toBeInTheDocument(); + expect(loadAuthenticatedShellUI).toHaveBeenCalledOnce(); + expect(loadAuthenticatedShellUI).toHaveBeenCalledWith(1); + expect(api.themes.getActive).toHaveBeenCalledWith({ timeout: 10_000 }); + }); + + it('keeps the desktop loader visible until its authenticated shell is ready', async () => { + currentRoute.set({ view: 'my-work', path: '/', params: {} }); + let resolveShell; + loadAuthenticatedShellUI.mockReturnValueOnce( + new Promise((resolve) => { + resolveShell = resolve; + }) + ); + render(App); + await waitFor(() => expect(loadAuthenticatedShellUI).toHaveBeenCalledWith(1)); + expect(screen.getByTestId('branded-loader')).toBeInTheDocument(); + expect(screen.queryByTestId('app-shell-ready')).not.toBeInTheDocument(); + resolveShell(true); + expect(await screen.findByTestId('app-shell-ready')).toBeInTheDocument(); + expect(screen.queryByTestId('branded-loader')).not.toBeInTheDocument(); + }); + + it('loads the active theme after an interactive login', async () => { + const activeTheme = { + nav_background_color_light: '#112233', + nav_text_color_light: '#fefefe', + nav_background_color_dark: '#010203', + nav_text_color_dark: '#eeeeee', + }; + authStore.init.mockResolvedValue({ status: 'unauthenticated' }); + api.themes.getActive.mockResolvedValue(activeTheme); + + render(App); + + expect(await screen.findByTestId('dialog-stub')).toBeInTheDocument(); + expect(api.themes.getActive).not.toHaveBeenCalled(); + + authStore.set(authenticatedState); + + await waitFor(() => expect(api.themes.getActive).toHaveBeenCalledWith({ timeout: 10_000 })); + expect(themeStore.setActiveTheme).toHaveBeenLastCalledWith(activeTheme); + expect(loadAuthenticatedShellUI).toHaveBeenCalledWith(1); + }); + + it('ignores an authenticated theme response that arrives after logout', async () => { + const activeTheme = { + nav_background_color_light: '#112233', + nav_text_color_light: '#fefefe', + nav_background_color_dark: '#010203', + nav_text_color_dark: '#eeeeee', + }; + let resolveTheme; + const themeRequest = new Promise((resolve) => { + resolveTheme = resolve; + }); + api.themes.getActive.mockReturnValue(themeRequest); + + render(App); + + expect(await screen.findByTestId('app-shell-ready')).toBeInTheDocument(); + await waitFor(() => expect(api.themes.getActive).toHaveBeenCalledOnce()); + + authStore.set({ + isAuthenticated: false, + loading: false, + currentUser: null, + }); + await waitFor(() => + expect(themeStore.setActiveTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ nav_background_color_light: '#ffffff' }) + ) + ); + + resolveTheme(activeTheme); + await themeRequest; + await Promise.resolve(); + + expect(themeStore.setActiveTheme).not.toHaveBeenCalledWith(activeTheme); + expect(resetAuthenticatedShellUILoad).toHaveBeenCalledOnce(); + }); + + it('shows a connection error and retries bootstrap in place', async () => { + api.setup.getStatus + .mockRejectedValueOnce(Object.assign(new Error('offline'), { code: 'NETWORK_ERROR' })) + .mockResolvedValueOnce({ setup_completed: true }); + + render(App); + + expect(await screen.findByTestId('startup-error')).toBeInTheDocument(); + expect(screen.getByTestId('startup-retry')).toBeInTheDocument(); + await fireEvent.click(screen.getByTestId('startup-retry')); + + await waitFor(() => expect(screen.getByTestId('app-shell-ready')).toBeInTheDocument()); + expect(api.setup.getStatus).toHaveBeenCalledTimes(2); + }); + + it('times out a stalled locale chunk instead of loading forever', async () => { + vi.useFakeTimers(); + i18n.init.mockReturnValue(new Promise(() => {})); + + render(App); + await vi.advanceTimersByTimeAsync(10_000); + + expect(screen.getByTestId('startup-error')).toBeInTheDocument(); + expect(screen.getByTestId('startup-retry')).toBeInTheDocument(); + expect(api.setup.getStatus).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/lib/api/bulkOperations.test.js b/frontend/src/lib/api/bulkOperations.test.js index 93a3ce6..bf61312 100644 --- a/frontend/src/lib/api/bulkOperations.test.js +++ b/frontend/src/lib/api/bulkOperations.test.js @@ -1,14 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./core.js', () => ({ + // The planning module also constructs retained enum configuration clients. fetchAPI: vi.fn(), + fetchV2Data: vi.fn(), })); vi.mock('../utils/crossTabSync.js', () => ({ notifyItemMutation: vi.fn(), })); -const { fetchAPI } = await import('./core.js'); +const { fetchAPI, fetchV2Data } = await import('./core.js'); const { notifyItemMutation } = await import('../utils/crossTabSync.js'); const { items } = await import('./items.js'); const { iterations } = await import('./milestones.js'); @@ -16,19 +18,22 @@ const { iterations } = await import('./milestones.js'); describe('bulk operation API clients', () => { beforeEach(() => { fetchAPI.mockReset(); + fetchV2Data.mockReset(); notifyItemMutation.mockReset(); }); + afterEach(() => expect(fetchAPI).not.toHaveBeenCalled()); + it('sends one atomic request for a flexible work-item field patch', async () => { - fetchAPI.mockResolvedValue({ updated_count: 100, items: [] }); + fetchV2Data.mockResolvedValue({ updated_count: 100, items: [] }); await items.bulkUpdate( Array.from({ length: 100 }, (_, index) => index + 1), { priority_id: 7, iteration_id: 12 } ); - expect(fetchAPI).toHaveBeenCalledTimes(1); - expect(fetchAPI).toHaveBeenCalledWith('/items/bulk-update', { + expect(fetchV2Data).toHaveBeenCalledTimes(1); + expect(fetchV2Data).toHaveBeenCalledWith('/items/bulk-update', { method: 'POST', body: JSON.stringify({ item_ids: Array.from({ length: 100 }, (_, index) => index + 1), @@ -39,7 +44,7 @@ describe('bulk operation API clients', () => { }); it('sends distinct roadmap date patches in one atomic request', async () => { - fetchAPI.mockResolvedValue({ updated_count: 2, items: [] }); + fetchV2Data.mockResolvedValue({ updated_count: 2, items: [] }); const patches = [ { item_id: 11, set: { start_date: '2026-08-10' } }, { item_id: 12, set: { end_date: '2026-08-20' } }, @@ -47,8 +52,8 @@ describe('bulk operation API clients', () => { await items.bulkPatch(patches); - expect(fetchAPI).toHaveBeenCalledOnce(); - expect(fetchAPI).toHaveBeenCalledWith('/items/bulk-patch', { + expect(fetchV2Data).toHaveBeenCalledOnce(); + expect(fetchV2Data).toHaveBeenCalledWith('/items/bulk-patch', { method: 'POST', body: JSON.stringify({ patches }), }); @@ -56,27 +61,38 @@ describe('bulk operation API clients', () => { }); it('loads the hierarchy date projection with one request', async () => { - fetchAPI.mockResolvedValue({ items: [], truncated: false }); + fetchV2Data.mockResolvedValue({ items: [], truncated: false }); await items.getRoadmapHierarchyDates([11, 12]); - expect(fetchAPI).toHaveBeenCalledOnce(); - expect(fetchAPI).toHaveBeenCalledWith('/items/roadmap-hierarchy-dates', { + expect(fetchV2Data).toHaveBeenCalledOnce(); + expect(fetchV2Data).toHaveBeenCalledWith('/items/roadmap-hierarchy-dates', { method: 'POST', body: JSON.stringify({ root_ids: [11, 12] }), }); expect(notifyItemMutation).not.toHaveBeenCalled(); }); - it('completes a 500-item iteration with one client request', async () => { - fetchAPI.mockResolvedValue({ moved_count: 500, items: [] }); + it('forwards a 500-item completion result with one v2 client call', async () => { + fetchV2Data.mockResolvedValue({ moved_count: 500, items: [] }); - await iterations.complete(41, 42); + expect(await iterations.complete(41, 42)).toEqual({ moved_count: 500, items: [] }); - expect(fetchAPI).toHaveBeenCalledTimes(1); - expect(fetchAPI).toHaveBeenCalledWith('/iterations/41/complete', { + expect(fetchV2Data).toHaveBeenCalledTimes(1); + expect(fetchV2Data).toHaveBeenCalledWith('/iterations/41/complete', { method: 'POST', body: JSON.stringify({ move_incomplete_to_iteration_id: 42 }), }); }); + + it.each([ + ['bulkUpdate', () => items.bulkUpdate([11], { priority_id: 7 })], + ['bulkPatch', () => items.bulkPatch([{ item_id: 11, set: { priority_id: 7 } }])], + ])('%s propagates rejection without broadcasting a successful mutation', async (_name, call) => { + const denied = Object.assign(new Error('Denied'), { status: 404, code: 'not_found' }); + fetchV2Data.mockRejectedValueOnce(denied); + await expect(call()).rejects.toBe(denied); + expect(fetchV2Data).toHaveBeenCalledOnce(); + expect(notifyItemMutation).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/lib/api/collections.test.js b/frontend/src/lib/api/collections.test.js index 728f0b8..0921cf3 100644 --- a/frontend/src/lib/api/collections.test.js +++ b/frontend/src/lib/api/collections.test.js @@ -1,37 +1,40 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('./core.js', () => ({ - fetchAPI: vi.fn(), -})); - -vi.mock('./createCrudClient.js', () => ({ - createCrudClient: vi.fn(() => ({})), -})); - -const { fetchAPI } = await import('./core.js'); -const { collections } = await import('./collections.js'); - -describe('collections API', () => { - beforeEach(() => fetchAPI.mockReset()); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { collections } from './collections.js'; + +describe('collections v2 API', () => { + let fetchSpy; + const bootstrap = { board_configuration: { id: 9 }, statuses: [], referenced_workspace_ids: [4] }; + + beforeEach(() => { + fetchSpy = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ data: bootstrap }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + }); + afterEach(() => vi.unstubAllGlobals()); it('requests the Board Configuration bootstrap with workspace fallback context', async () => { - fetchAPI.mockResolvedValue({}); - - await collections.getBoardConfigurationBootstrap(9, 4); - - expect(fetchAPI).toHaveBeenCalledOnce(); - expect(fetchAPI).toHaveBeenCalledWith( - '/collections/9/board-configuration/bootstrap?workspace_id=4' + expect(await collections.getBoardConfigurationBootstrap(9, 4)).toEqual(bootstrap); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe( + '/api/v2/collections/9/board-configuration/bootstrap?workspace_id=4' ); + expect(fetchSpy.mock.calls[0][1].credentials).toBe('same-origin'); }); - it('requests a default workspace Board Configuration bootstrap', async () => { - fetchAPI.mockResolvedValue({}); - - await collections.getBoardConfigurationBootstrap(null, 4); + it('requests the default Board Configuration through the workspace scope path', async () => { + expect(await collections.getBoardConfigurationBootstrap(null, 4)).toEqual(bootstrap); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/workspaces/4/board-configuration/bootstrap'); + }); - expect(fetchAPI).toHaveBeenCalledWith( - '/collections/default/board-configuration/bootstrap?workspace_id=4' - ); + it('does not invent workspace fallback context for an explicit collection', async () => { + expect(await collections.getBoardConfigurationBootstrap(9)).toEqual(bootstrap); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/collections/9/board-configuration/bootstrap'); }); }); diff --git a/frontend/src/lib/api/items.test.js b/frontend/src/lib/api/items.test.js index 5e606f1..0bdc874 100644 --- a/frontend/src/lib/api/items.test.js +++ b/frontend/src/lib/api/items.test.js @@ -43,16 +43,18 @@ describe('items API cross-tab broadcast', () => { posted = []; vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); - fetchSpy = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ id: 42, title: 'x' }), { - status: 200, - headers: { 'Content-Type': 'application/json', Date: new Date().toUTCString() }, - }) + fetchSpy = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ data: { id: 42, title: 'x' } }), { + status: 200, + headers: { 'Content-Type': 'application/json', Date: new Date().toUTCString() }, + }) ); vi.stubGlobal('fetch', fetchSpy); }); afterEach(() => { + for (const channel of [...FakeBroadcastChannel.instances]) channel.close(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -67,8 +69,11 @@ describe('items API cross-tab broadcast', () => { it('create broadcasts a notice with the new item id from the response', async () => { const { mod, peer } = await captureBroadcasts(); - const result = await mod.items.create({ title: 'x' }); + const result = await mod.items.create({ title: 'x', workspace_id: 7 }); expect(result.id).toBe(42); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items'); + expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ title: 'x', workspace_id: 7 }); expect(posted).toEqual([expect.objectContaining({ type: 'create', itemId: 42 })]); peer.close(); }); @@ -76,13 +81,17 @@ describe('items API cross-tab broadcast', () => { it('update broadcasts with the id arg', async () => { const { mod, peer } = await captureBroadcasts(); await mod.items.update(7, { title: 'y' }); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/7'); + expect(fetchSpy.mock.calls[0][1].method).toBe('PATCH'); + expect(fetchSpy.mock.calls[0][1].headers['Content-Type']).toBe('application/merge-patch+json'); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ title: 'y' }); expect(posted[0]).toEqual(expect.objectContaining({ type: 'update', itemId: 7 })); peer.close(); }); it('transition broadcasts with the id arg', async () => { fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ item: { id: 9, status_id: 2 } }), { + new Response(JSON.stringify({ data: { item: { id: 9, status_id: 2 } } }), { status: 200, headers: { 'Content-Type': 'application/json', Date: new Date().toUTCString() }, }) @@ -90,13 +99,23 @@ describe('items API cross-tab broadcast', () => { const { mod, peer } = await captureBroadcasts(); const item = await mod.items.transition(9, 2); expect(item.id).toBe(9); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/9/transition'); + expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ to_status_id: 2 }); expect(posted[0]).toEqual(expect.objectContaining({ type: 'transition', itemId: 9 })); peer.close(); }); it('updateFracIndex broadcasts a reorder notice', async () => { const { mod, peer } = await captureBroadcasts(); - await mod.items.updateFracIndex(3, { frac_index: 'a0' }); + await mod.items.updateFracIndex(3, { previous_item_id: 2, next_item_id: 4 }); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/3/rank'); + expect(fetchSpy.mock.calls[0][1].method).toBe('PATCH'); + expect(fetchSpy.mock.calls[0][1].headers['Content-Type']).toBe('application/merge-patch+json'); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + previous_item_id: 2, + next_item_id: 4, + }); expect(posted[0]).toEqual(expect.objectContaining({ type: 'reorder', itemId: 3 })); peer.close(); }); @@ -105,7 +124,7 @@ describe('items API cross-tab broadcast', () => { const { mod, peer } = await captureBroadcasts(); await mod.items.previewWorkspaceMove(7, { destination_workspace_id: 9 }); - expect(fetchSpy.mock.calls[0][0]).toBe('/api/items/7/move-workspace/preview'); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/7/move-workspace/preview'); expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ destination_workspace_id: 9 }); expect(posted).toEqual([]); @@ -122,7 +141,7 @@ describe('items API cross-tab broadcast', () => { }; await mod.items.moveWorkspace(7, payload); - expect(fetchSpy.mock.calls[0][0]).toBe('/api/items/7/move-workspace'); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/7/move-workspace'); expect(fetchSpy.mock.calls[0][1].method).toBe('POST'); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual(payload); expect(posted[0]).toEqual(expect.objectContaining({ type: 'update', itemId: 7 })); @@ -131,43 +150,63 @@ describe('items API cross-tab broadcast', () => { it('does not broadcast when the request rejects', async () => { fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ error: 'boom' }), { + new Response(JSON.stringify({ error: { code: 'internal_error', message: 'boom' } }), { status: 500, headers: { 'Content-Type': 'application/json', Date: new Date().toUTCString() }, }) ); const { mod, peer } = await captureBroadcasts(); - await expect(mod.items.update(1, { title: 'z' })).rejects.toBeDefined(); + await expect(mod.items.update(1, { title: 'z' })).rejects.toMatchObject({ + status: 500, + code: 'internal_error', + message: 'boom', + }); expect(posted).toEqual([]); peer.close(); }); it('loads the numeric item-detail summary with the optional surface selector', async () => { + const summary = { item: { id: 42, title: 'x' }, children: [], ancestors: [], watching: false }; + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ data: summary }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); const { items } = await import('./items.js'); const controller = new AbortController(); - await items.getDetailSummary(42, { surface: 'mobile', signal: controller.signal }); + expect( + await items.getDetailSummary(42, { surface: 'mobile', signal: controller.signal }) + ).toEqual(summary); expect(fetchSpy).toHaveBeenCalledOnce(); - expect(fetchSpy.mock.calls[0][0]).toBe('/api/items/42/detail-summary?surface=mobile'); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/items/42/detail-summary?surface=mobile'); expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); }); it('loads a key-addressed item-detail summary without a preliminary item request', async () => { + const summary = { item: { id: 42, title: 'x' }, children: [], ancestors: [], watching: false }; + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ data: summary }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); const { items } = await import('./items.js'); - await items.getDetailSummaryByKey('WI', 689); + expect(await items.getDetailSummaryByKey('WI', 689)).toEqual(summary); expect(fetchSpy).toHaveBeenCalledOnce(); - expect(fetchSpy.mock.calls[0][0]).toBe('/api/workspaces/WI/items/689/detail-summary'); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/workspaces/WI/items/689/detail-summary'); }); it('resolves the first item from the filtered backlog', async () => { fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ - items: [{ id: 11 }], - pagination: { page: 1, limit: 1, total: 125, total_pages: 125 }, + data: [{ id: 11 }], + pagination: { page: 1, page_size: 1, total_items: 125, total_pages: 125 }, }), { status: 200, @@ -184,11 +223,11 @@ describe('items API cross-tab broadcast', () => { expect(boundary).toEqual({ id: 11 }); expect(fetchSpy).toHaveBeenCalledOnce(); - expect(fetchSpy.mock.calls[0][0]).toContain('/api/items/backlog?'); + expect(fetchSpy.mock.calls[0][0]).toContain('/api/v2/items/backlog?'); expect(fetchSpy.mock.calls[0][0]).toContain('workspace_id=7'); expect(fetchSpy.mock.calls[0][0]).toContain('sub_ql=priority+%3D+high'); expect(fetchSpy.mock.calls[0][0]).toContain('page=1'); - expect(fetchSpy.mock.calls[0][0]).toContain('limit=1'); + expect(fetchSpy.mock.calls[0][0]).toContain('page_size=1'); }); it('uses the current total to resolve the true last backlog item', async () => { @@ -196,8 +235,8 @@ describe('items API cross-tab broadcast', () => { .mockResolvedValueOnce( new Response( JSON.stringify({ - items: [{ id: 11 }], - pagination: { page: 1, limit: 1, total: 125, total_pages: 125 }, + data: [{ id: 11 }], + pagination: { page: 1, page_size: 1, total_items: 125, total_pages: 125 }, }), { status: 200, @@ -211,8 +250,8 @@ describe('items API cross-tab broadcast', () => { .mockResolvedValueOnce( new Response( JSON.stringify({ - items: [{ id: 135 }], - pagination: { page: 125, limit: 1, total: 125, total_pages: 125 }, + data: [{ id: 135 }], + pagination: { page: 125, page_size: 1, total_items: 125, total_pages: 125 }, }), { status: 200, @@ -232,6 +271,6 @@ describe('items API cross-tab broadcast', () => { expect(fetchSpy.mock.calls[0][0]).toContain('collection_id=23'); expect(fetchSpy.mock.calls[1][0]).toContain('collection_id=23'); expect(fetchSpy.mock.calls[1][0]).toContain('page=125'); - expect(fetchSpy.mock.calls[1][0]).toContain('limit=1'); + expect(fetchSpy.mock.calls[1][0]).toContain('page_size=1'); }); }); diff --git a/frontend/src/lib/api/milestones.test.js b/frontend/src/lib/api/milestones.test.js index 437cd41..d269d15 100644 --- a/frontend/src/lib/api/milestones.test.js +++ b/frontend/src/lib/api/milestones.test.js @@ -1,37 +1,180 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { iterations, milestones } from './milestones.js'; -vi.mock('./core.js', () => ({ - fetchAPI: vi.fn(), -})); +describe('planning v2 API transport', () => { + let fetchSpy; -vi.mock('./createCrudClient.js', () => ({ - createCrudClient: vi.fn(() => ({})), -})); + function json(data, pagination, status = 200) { + return new Response(JSON.stringify({ data, ...(pagination ? { pagination } : {}) }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + afterEach(() => vi.unstubAllGlobals()); -const { fetchAPI } = await import('./core.js'); -const { milestones } = await import('./milestones.js'); + function expectRequest(index, url, method, body) { + const [actualURL, init] = fetchSpy.mock.calls[index]; + expect(actualURL).toBe(url); + expect(init.method).toBe(method); + expect(init.credentials).toBe('same-origin'); + expect(init.headers['Content-Type']).toBe( + method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' + ); + expect(JSON.parse(init.body)).toEqual(body); + return init; + } -describe('milestones API', () => { - beforeEach(() => fetchAPI.mockReset()); + it('requests test statistics for unique milestone IDs in one v2 POST and maps entries', async () => { + const first = { total_test_cases: 3 }, + second = { total_test_cases: 4 }; + fetchSpy.mockResolvedValueOnce( + json([ + { milestone_id: 3, statistics: first }, + { milestone_id: 4, statistics: second }, + ]) + ); + expect(await milestones.getTestStatisticsMany([3, 4, 3])).toEqual({ 3: first, 4: second }); + expect(fetchSpy).toHaveBeenCalledOnce(); + expectRequest(0, '/api/v2/milestones/test-statistics', 'POST', { ids: [3, 4] }); + }); - it('requests test statistics for unique milestone IDs in one call', async () => { - fetchAPI.mockResolvedValue({}); + it('sends the release idempotency key and returns the unwrapped milestone', async () => { + const released = { id: 9, status: 'completed' }; + fetchSpy.mockResolvedValueOnce(json(released)); + expect(await milestones.release(9, { tag_name: 'v0.8.4' }, 'release-request-1')).toEqual( + released + ); + expect(fetchSpy).toHaveBeenCalledOnce(); + const init = expectRequest(0, '/api/v2/milestones/9/release', 'POST', { tag_name: 'v0.8.4' }); + expect(init.headers['Idempotency-Key']).toBe('release-request-1'); + }); - await milestones.getTestStatisticsMany([3, 4, 3]); + it.each([ + ['iterations', iterations], + ['milestones', milestones], + ])( + '%s fetches every local page and combines the global list without forwarding legacy scope filters', + async (resource, api) => { + const localFirst = Array.from({ length: 100 }, (_, index) => ({ id: index + 100 })); + const localLast = [{ id: 200 }], + global = [{ id: 301 }]; + const signal = new AbortController().signal; + const status = resource === 'iterations' ? 'active' : 'in-progress'; + fetchSpy.mockImplementation(async (url) => { + const request = new URL(url, 'https://windshift.invalid'); + const local = request.pathname === `/api/v2/workspaces/42/${resource}`; + expect([`/api/v2/workspaces/42/${resource}`, `/api/v2/${resource}`]).toContain( + request.pathname + ); + expect(request.searchParams.get('status')).toBe(status); + expect(request.searchParams.get('page_size')).toBe('100'); + expect(request.searchParams.has('workspace_id')).toBe(false); + expect(request.searchParams.has('include_global')).toBe(false); + expect(request.searchParams.has('is_global')).toBe(false); + const page = Number(request.searchParams.get('page')); + expect(local ? [1, 2] : [1]).toContain(page); + return json(local ? (page === 1 ? localFirst : localLast) : global, { + page, + page_size: 100, + total_items: local ? 101 : 1, + total_pages: local ? 2 : 1, + }); + }); + expect( + await api.getAll( + { workspace_id: 42, include_global: true, is_global: false, status }, + { signal } + ) + ).toEqual([...localFirst, ...localLast, ...global]); + expect(fetchSpy).toHaveBeenCalledTimes(3); + for (const [, init] of fetchSpy.mock.calls) { + expect(init.signal).toBe(signal); + expect(init.credentials).toBe('same-origin'); + } + } + ); - expect(fetchAPI).toHaveBeenCalledOnce(); - expect(fetchAPI).toHaveBeenCalledWith('/milestones/test-statistics?ids=3,4'); + it.each([ + ['iterations', iterations], + ['milestones', milestones], + ])('%s respects explicit local-only list scope', async (resource, api) => { + fetchSpy.mockResolvedValueOnce( + json([{ id: 8 }], { page: 1, page_size: 100, total_items: 1, total_pages: 1 }) + ); + expect(await api.getAll({ workspace_id: 42, include_global: false })).toEqual([{ id: 8 }]); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe( + `/api/v2/workspaces/42/${resource}?page_size=100&page=1` + ); }); - it('sends the release idempotency key with the mutation', async () => { - fetchAPI.mockResolvedValue({}); + it.each([ + ['iterations', iterations], + ['milestones', milestones], + ])( + '%s selects create scope through the path and strips scope fields from JSON', + async (resource, api) => { + fetchSpy + .mockResolvedValueOnce(json({ id: 9 }, undefined, 201)) + .mockResolvedValueOnce(json({ id: 10 }, undefined, 201)); + const fields = + resource === 'iterations' + ? { start_date: '2026-07-01', end_date: '2026-07-14', status: 'planned' } + : { target_date: '2026-07-14', status: 'planning' }; + expect( + await api.create({ name: 'Global', ...fields, is_global: true, workspace_id: 99 }) + ).toEqual({ + id: 9, + }); + expect( + await api.create({ name: 'Local', ...fields, is_global: false, workspace_id: 42 }) + ).toEqual({ + id: 10, + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expectRequest(0, `/api/v2/${resource}`, 'POST', { name: 'Global', ...fields }); + expectRequest(1, `/api/v2/workspaces/42/${resource}`, 'POST', { name: 'Local', ...fields }); + } + ); - await milestones.release(9, { tag_name: 'v0.8.4' }, 'release-request-1'); + it.each([ + ['iterations', iterations, 'type_id'], + ['milestones', milestones, 'category_id'], + ])( + '%s sends only mutable merge-patch fields, preserving empty and null values', + async (resource, api, nullableField) => { + fetchSpy.mockResolvedValueOnce( + json({ id: 8, name: 'Renamed', description: '', [nullableField]: null }) + ); + expect( + await api.update(8, { + name: 'Renamed', + description: '', + [nullableField]: null, + is_global: false, + workspace_id: 42, + created_at: 'ignored', + }) + ).toEqual({ id: 8, name: 'Renamed', description: '', [nullableField]: null }); + expect(fetchSpy).toHaveBeenCalledOnce(); + expectRequest(0, `/api/v2/${resource}/8`, 'PATCH', { + name: 'Renamed', + description: '', + [nullableField]: null, + }); + } + ); - expect(fetchAPI).toHaveBeenCalledWith('/milestones/9/release', { - method: 'POST', - headers: { 'Idempotency-Key': 'release-request-1' }, - body: JSON.stringify({ tag_name: 'v0.8.4' }), - }); + it('posts deduplicated iteration progress IDs and maps the response by identity', async () => { + const progress = { iteration_id: 8, total_items: 2 }; + fetchSpy.mockResolvedValueOnce(json([{ iteration_id: 8, progress }])); + expect(await iterations.getProgressMany([8, 8])).toEqual({ 8: progress }); + expect(fetchSpy).toHaveBeenCalledOnce(); + expectRequest(0, '/api/v2/iterations/progress', 'POST', { ids: [8] }); }); }); diff --git a/frontend/src/lib/api/pages.test.js b/frontend/src/lib/api/pages.test.js index 9379e5f..c02e04b 100644 --- a/frontend/src/lib/api/pages.test.js +++ b/frontend/src/lib/api/pages.test.js @@ -7,22 +7,23 @@ import { pages } from './pages.js'; * assert what got sent. Catching a path or verb mismatch here surfaces a * server-vs-client wiring drift before it lands in production. * - * Routes mirror `internal/handlers/router.go` and `frontend/src/lib/api/core.js` - * (which prefixes `/api`). + * Page routes mirror the v2 adapter and use real frontend transport decoding. + * Knowledge search remains on its explicitly supported legacy route. */ describe('pages API client', () => { let fetchSpy; beforeEach(() => { - fetchSpy = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - Date: new Date().toUTCString(), - }, - }) + fetchSpy = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ data: { id: 7, title: 'Page result' } }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + Date: new Date().toUTCString(), + }, + }) ); vi.stubGlobal('fetch', fetchSpy); }); @@ -31,31 +32,52 @@ describe('pages API client', () => { vi.unstubAllGlobals(); }); - const lastCall = () => fetchSpy.mock.calls[0]; + const lastCall = () => { + expect(fetchSpy).toHaveBeenCalledOnce(); + return fetchSpy.mock.calls[0]; + }; - test('getTree → GET /api/workspaces/:id/pages/tree', async () => { - await pages.getTree(42); + function respond(body, status = 200) { + fetchSpy.mockResolvedValueOnce( + new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ); + } + + test('getAll → GET the flat v2 page metadata list', async () => { + const metadata = [ + { id: 7, title: 'Root', parent_id: null }, + { id: 8, title: 'Child', parent_id: 7 }, + ]; + respond({ data: metadata }); + expect(await pages.getAll(42)).toEqual(metadata); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/tree'); + expect(url).toBe('/api/v2/workspaces/42/pages'); expect(init.method).toBeUndefined(); // fetchAPI defaults to GET expect(init.credentials).toBe('same-origin'); }); - test('getPage → GET /api/workspaces/:id/pages/:pageId', async () => { - await pages.getPage(42, 7); + test('getPage → GET /api/v2/workspaces/:id/pages/:pageId', async () => { + expect(await pages.getPage(42, 7)).toEqual({ id: 7, title: 'Page result' }); const [url] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7'); + expect(url).toBe('/api/v2/workspaces/42/pages/7'); }); test('createPage → POST with body, optional parentId and isHome', async () => { - await pages.createPage(42, { - title: 'New', - content: 'body', - parentId: 5, - isHome: true, - }); + const created = { id: 9, title: 'New', content: 'body', parent_id: 5, is_home: true }; + respond({ data: created }, 201); + expect( + await pages.createPage(42, { + title: 'New', + content: 'body', + parentId: 5, + isHome: true, + }) + ).toEqual(created); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages'); + expect(url).toBe('/api/v2/workspaces/42/pages'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ title: 'New', @@ -76,11 +98,12 @@ describe('pages API client', () => { expect(body.metadata).toEqual({}); }); - test('updatePage → PUT with title and content only', async () => { + test('updatePage → PATCH with title and content only', async () => { await pages.updatePage(42, 7, { title: 'Edited', content: 'rewritten' }); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7'); - expect(init.method).toBe('PUT'); + expect(url).toBe('/api/v2/workspaces/42/pages/7'); + expect(init.method).toBe('PATCH'); + expect(init.headers['Content-Type']).toBe('application/merge-patch+json'); expect(JSON.parse(init.body)).toEqual({ title: 'Edited', content: 'rewritten', @@ -101,17 +124,18 @@ describe('pages API client', () => { }); }); - test('archivePage → DELETE /api/workspaces/:id/pages/:pageId', async () => { - await pages.archivePage(42, 7); + test('archivePage → DELETE /api/v2/workspaces/:id/pages/:pageId', async () => { + respond(null, 204); + expect(await pages.archivePage(42, 7)).toBeUndefined(); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7'); + expect(url).toBe('/api/v2/workspaces/42/pages/7'); expect(init.method).toBe('DELETE'); }); - test('movePage → POST /api/workspaces/:id/pages/:pageId/move with parent_id', async () => { + test('movePage → POST /api/v2/workspaces/:id/pages/:pageId/move with parent_id', async () => { await pages.movePage(42, 7, 11); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/move'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/move'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ parent_id: 11, @@ -151,28 +175,33 @@ describe('pages API client', () => { }); }); - test('getHistory → GET with default limit/offset query string', async () => { - await pages.getHistory(42, 7); + test('getHistory → GET with default v2 page/page_size query string', async () => { + const revisions = [{ id: 31, page_id: 7, revision_type: 'update' }]; + respond({ + data: revisions, + pagination: { page: 1, page_size: 50, total_items: 1, total_pages: 1 }, + }); + expect(await pages.getHistory(42, 7)).toEqual(revisions); const [url] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/history?limit=50&offset=0'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/history?page=1&page_size=50'); }); test('getHistory honors custom pagination', async () => { await pages.getHistory(42, 7, { limit: 10, offset: 20 }); const [url] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/history?limit=10&offset=20'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/history?page=3&page_size=10'); }); - test('getRevision → GET /api/workspaces/:id/pages/:pageId/history/:revId', async () => { + test('getRevision → GET /api/v2/workspaces/:id/pages/:pageId/history/:revId', async () => { await pages.getRevision(42, 7, 99); const [url] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/history/99'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/history/99'); }); - test('restoreRevision → POST /api/workspaces/:id/pages/:pageId/history/:revId/restore', async () => { + test('restoreRevision → POST /api/v2/workspaces/:id/pages/:pageId/history/:revId/restore', async () => { await pages.restoreRevision(42, 7, 99); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/history/99/restore'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/history/99/restore'); expect(init.method).toBe('POST'); }); @@ -185,7 +214,7 @@ describe('pages API client', () => { expectedContentHash: 'hash-1', }); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/diagrams'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/diagrams'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ name: 'Flow', @@ -195,7 +224,7 @@ describe('pages API client', () => { }); }); - test('updateDiagram → PUT with replacement name, scene, and content hash', async () => { + test('updateDiagram → PATCH with replacement name, scene, and content hash', async () => { const scene = { elements: [{ id: 'one', type: 'rectangle' }], appState: {}, @@ -207,8 +236,9 @@ describe('pages API client', () => { expectedContentHash: 'hash-2', }); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/diagrams/91'); - expect(init.method).toBe('PUT'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/diagrams/91'); + expect(init.method).toBe('PATCH'); + expect(init.headers['Content-Type']).toBe('application/merge-patch+json'); expect(JSON.parse(init.body)).toEqual({ name: 'Updated flow', excalidraw: scene, @@ -216,20 +246,20 @@ describe('pages API client', () => { }); }); - test('getPermissions → GET /api/workspaces/:id/pages/:pageId/permissions', async () => { + test('getPermissions → GET /api/v2/workspaces/:id/pages/:pageId/permissions', async () => { await pages.getPermissions(42, 7); const [url] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/permissions'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/permissions'); }); - test('grantPermission → POST /api/workspaces/:id/pages/:pageId/permissions with snake_cased body', async () => { + test('grantPermission → POST /api/v2/workspaces/:id/pages/:pageId/permissions with snake_cased body', async () => { await pages.grantPermission(42, 7, { principalType: 'user', principalId: 8, permissionLevel: 'edit', }); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/permissions'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/permissions'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ principal_type: 'user', @@ -238,23 +268,27 @@ describe('pages API client', () => { }); }); - test('revokePermission → DELETE /api/workspaces/:id/pages/:pageId/permissions/:permId', async () => { - await pages.revokePermission(42, 7, 99); + test('revokePermission → DELETE /api/v2/workspaces/:id/pages/:pageId/permissions/:permId', async () => { + respond(null, 204); + expect(await pages.revokePermission(42, 7, 99)).toBeUndefined(); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/permissions/99'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/permissions/99'); expect(init.method).toBe('DELETE'); }); - test('setInheritance → PATCH /api/workspaces/:id/pages/:pageId/inheritance with inherit_permissions body', async () => { + test('setInheritance → PATCH /api/v2/workspaces/:id/pages/:pageId/inheritance with inherit_permissions body', async () => { await pages.setInheritance(42, 7, false); const [url, init] = lastCall(); - expect(url).toBe('/api/workspaces/42/pages/7/inheritance'); + expect(url).toBe('/api/v2/workspaces/42/pages/7/inheritance'); expect(init.method).toBe('PATCH'); + expect(init.headers['Content-Type']).toBe('application/merge-patch+json'); expect(JSON.parse(init.body)).toEqual({ inherit_permissions: false }); }); test('searchKnowledge → GET /api/workspaces/:id/knowledge/search with q and limit', async () => { - await pages.searchKnowledge(42, 'onboarding', { limit: 10 }); + const matches = { results: [{ id: 7, title: 'Onboarding' }] }; + respond(matches); + expect(await pages.searchKnowledge(42, 'onboarding', { limit: 10 })).toEqual(matches); const [url] = lastCall(); expect(url).toBe('/api/workspaces/42/knowledge/search?q=onboarding&limit=10'); }); @@ -264,4 +298,27 @@ describe('pages API client', () => { const [url] = lastCall(); expect(url).toBe('/api/workspaces/42/knowledge/search?q=onboarding&limit=25'); }); + + test('updatePage keeps omitted fields absent while forwarding an explicit empty value', async () => { + await pages.updatePage(42, 7, { content: '' }); + const [, init] = lastCall(); + expect(JSON.parse(init.body)).toEqual({ content: '' }); + expect(init.headers['Content-Type']).toBe('application/merge-patch+json'); + }); + + test('propagates a structured v2 conflict instead of returning data', async () => { + respond( + { error: { code: 'conflict', message: 'Page has changed' }, request_id: 'synthetic-request' }, + 409 + ); + await expect( + pages.updatePage(42, 7, { content: 'New', expectedContentHash: 'old' }) + ).rejects.toMatchObject({ + status: 409, + code: 'conflict', + message: 'Page has changed', + requestId: 'synthetic-request', + }); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/lib/api/tests/testRuns.test.js b/frontend/src/lib/api/tests/testRuns.test.js index e1a5cb3..ad8c265 100644 --- a/frontend/src/lib/api/tests/testRuns.test.js +++ b/frontend/src/lib/api/tests/testRuns.test.js @@ -1,25 +1,57 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { testRuns } from './testRuns.js'; -vi.mock('../core.js', () => ({ - fetchAPI: vi.fn(), -})); - -vi.mock('../createCrudClient.js', () => ({ - createCrudClient: vi.fn(() => ({})), -})); - -const { fetchAPI } = await import('../core.js'); -const { testRuns } = await import('./testRuns.js'); - -describe('test runs API', () => { - beforeEach(() => fetchAPI.mockReset()); - - it('loads the complete run graph through one detail request', async () => { - fetchAPI.mockResolvedValue({ run: { id: 9 } }); +describe('test runs v2 API', () => { + let fetchSpy; + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + afterEach(() => vi.unstubAllGlobals()); - await testRuns.getDetail(3, 9); + function respond(data) { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + } - expect(fetchAPI).toHaveBeenCalledOnce(); - expect(fetchAPI).toHaveBeenCalledWith('/workspaces/3/test-runs/9/detail'); + it('loads and unwraps the complete run graph through one v2 detail request', async () => { + const graph = { + run: { id: 9 }, + test_cases: [{ id: 12 }], + results: [{ id: 13 }], + step_results: [{ id: 17 }], + }; + respond(graph); + expect(await testRuns.getDetail(3, 9)).toEqual(graph); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v2/workspaces/3/test-runs/9/detail'); + expect(fetchSpy.mock.calls[0][1].credentials).toBe('same-origin'); }); + + it.each([ + [ + 'result', + (body) => testRuns.updateResult(3, 9, 13, body), + 'results/13', + { id: 13, status: 'passed', notes: '' }, + ], + ['step', (body) => testRuns.updateStepResult(3, 9, 17, body), 'steps/17', { updated: true }], + ])( + 'updates one %s with merge-patch without losing explicit empty fields', + async (_name, call, suffix, result) => { + const body = { status: 'passed', notes: '' }; + respond(result); + expect(await call(body)).toEqual(result); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`/api/v2/workspaces/3/test-runs/9/${suffix}`); + expect(init.method).toBe('PATCH'); + expect(init.headers['Content-Type']).toBe('application/merge-patch+json'); + expect(JSON.parse(init.body)).toEqual(body); + } + ); }); diff --git a/frontend/src/lib/features/agents/AgentCreate.test.js b/frontend/src/lib/features/agents/AgentCreate.test.js index 6a0fd62..4e4ba4f 100644 --- a/frontend/src/lib/features/agents/AgentCreate.test.js +++ b/frontend/src/lib/features/agents/AgentCreate.test.js @@ -13,6 +13,7 @@ vi.mock('../../api.js', () => ({ getEnabled: vi.fn(), }, workspaceSCM: { + getConnectionsOverview: vi.fn(), getConnections: vi.fn(), getLinkedRepos: vi.fn(), }, @@ -42,6 +43,7 @@ import { getShortcutDisplay } from '../../utils/keyboardShortcuts.js'; import AgentCreate from './AgentCreate.svelte'; const storedValues = new Map(); +const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); const localStorageMock = { clear: () => storedValues.clear(), getItem: (key) => storedValues.get(key) ?? null, @@ -83,12 +85,15 @@ beforeEach(() => { { id: 9, name: 'Primary model', model: 'gpt-example' }, ]); api.workspaceSCM.getConnections.mockResolvedValue([]); + api.workspaceSCM.getConnectionsOverview.mockResolvedValue([]); api.workspaceSCM.getLinkedRepos.mockResolvedValue([]); api.actionCapabilities.getForWorkspace.mockResolvedValue([]); }); afterEach(() => { cleanup(); + if (originalLocalStorage) Object.defineProperty(window, 'localStorage', originalLocalStorage); + else delete window.localStorage; vi.clearAllMocks(); }); diff --git a/frontend/src/lib/features/testing/testRunDetailData.test.js b/frontend/src/lib/features/testing/testRunDetailData.test.js index 3bdb939..5cede8c 100644 --- a/frontend/src/lib/features/testing/testRunDetailData.test.js +++ b/frontend/src/lib/features/testing/testRunDetailData.test.js @@ -7,16 +7,23 @@ describe('test run detail request graph', () => { tests: { testRuns: { getDetail: vi.fn().mockResolvedValue({ - run: { id: 9, set_id: 4 }, - test_cases: [{ id: 1, test_steps: [{ id: 11 }] }, { id: 2 }], + run: { id: 9, plan_id: 4 }, + test_cases: [ + { id: 1, test_steps: [{ id: 11 }] }, + { id: 2, test_steps: [{ id: 12 }] }, + { id: 3 }, + ], results: [{ id: 21, test_case_id: 1 }], - step_results: { '1_11': { step_id: 11, status: 'passed' } }, + step_results: [ + { test_case_id: 1, step_id: 11, status: 'passed' }, + { test_case_id: 2, step_id: 12, status: 'failed' }, + ], }), get: vi.fn(), getResults: vi.fn(), getStepResults: vi.fn(), }, - testSets: { + testPlans: { get: vi.fn(), getTestCases: vi.fn(), }, @@ -31,17 +38,21 @@ describe('test run detail request graph', () => { expect(apiClient.tests.testRuns.get).not.toHaveBeenCalled(); expect(apiClient.tests.testRuns.getResults).not.toHaveBeenCalled(); expect(apiClient.tests.testRuns.getStepResults).not.toHaveBeenCalled(); - expect(apiClient.tests.testSets.get).not.toHaveBeenCalled(); - expect(apiClient.tests.testSets.getTestCases).not.toHaveBeenCalled(); + expect(apiClient.tests.testPlans.get).not.toHaveBeenCalled(); + expect(apiClient.tests.testPlans.getTestCases).not.toHaveBeenCalled(); expect(apiClient.tests.testCases.steps.getAll).not.toHaveBeenCalled(); expect(detail).toEqual({ - run: { id: 9, set_id: 4 }, + run: { id: 9, plan_id: 4 }, testCases: [ { id: 1, test_steps: [{ id: 11 }] }, - { id: 2, test_steps: [] }, + { id: 2, test_steps: [{ id: 12 }] }, + { id: 3, test_steps: [] }, ], results: [{ id: 21, test_case_id: 1 }], - stepResults: { '1_11': { step_id: 11, status: 'passed' } }, + stepResults: { + '1_11': { test_case_id: 1, step_id: 11, status: 'passed' }, + '2_12': { test_case_id: 2, step_id: 12, status: 'failed' }, + }, }); }); @@ -52,4 +63,24 @@ describe('test run detail request graph', () => { await expect(loadTestRunDetail(apiClient, 3, 9)).rejects.toThrow('Test run not found'); }); + + it('normalizes missing optional graph lists without additional requests', async () => { + const getDetail = vi.fn().mockResolvedValue({ run: { id: 9, plan_id: 4 } }); + const apiClient = { tests: { testRuns: { getDetail } } }; + await expect(loadTestRunDetail(apiClient, 3, 9)).resolves.toEqual({ + run: { id: 9, plan_id: 4 }, + testCases: [], + results: [], + stepResults: {}, + }); + expect(getDetail).toHaveBeenCalledExactlyOnceWith(3, 9); + }); + + it('propagates a failed aggregate request', async () => { + const failure = new Error('Run unavailable'); + const getDetail = vi.fn().mockRejectedValue(failure); + const apiClient = { tests: { testRuns: { getDetail } } }; + await expect(loadTestRunDetail(apiClient, 3, 9)).rejects.toBe(failure); + expect(getDetail).toHaveBeenCalledExactlyOnceWith(3, 9); + }); }); diff --git a/frontend/src/lib/features/workflows/WorkflowBuilder.test.js b/frontend/src/lib/features/workflows/WorkflowBuilder.test.js index 6a743e5..a828b0a 100644 --- a/frontend/src/lib/features/workflows/WorkflowBuilder.test.js +++ b/frontend/src/lib/features/workflows/WorkflowBuilder.test.js @@ -1,17 +1,39 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +const originalAnimate = Object.getOwnPropertyDescriptor(Element.prototype, 'animate'); +afterEach(cleanup); +afterAll(() => { + if (originalAnimate) Object.defineProperty(Element.prototype, 'animate', originalAnimate); + else delete Element.prototype.animate; +}); vi.mock('../../api.js', () => ({ api: { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn(), + objectTranslations: { list: vi.fn().mockResolvedValue([]) }, + statuses: { getAll: vi.fn() }, + workflows: { + getAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, }, })); vi.mock('../../router.js', () => ({ navigate: vi.fn() })); -vi.mock('../../stores/i18n.svelte.js', () => ({ t: vi.fn((key) => key) })); +vi.mock('../../stores/permissions.svelte.js', () => ({ + isSystemAdmin: { + subscribe: (run) => { + run(true); + return () => {}; + }, + }, +})); +vi.mock('../../stores/i18n.svelte.js', () => ({ + t: vi.fn((key) => key), + i18n: { locale: 'en', supportedLocales: [{ code: 'en', name: 'English' }] }, +})); vi.mock('../../stores/toasts.svelte.js', () => ({ errorToast: vi.fn() })); vi.mock('../../composables/useConfirm.js', () => ({ confirm: vi.fn() })); @@ -31,21 +53,16 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); - api.get.mockImplementation((path) => { - if (path === '/statuses') return Promise.resolve([{ id: 1, name: 'Open' }]); - if (path === '/workflows') { - return Promise.resolve([ - { - id: 7, - name: 'Company Workflow', - description: 'Workflow for the company project', - is_default: false, - }, - ]); - } - return Promise.resolve([]); - }); - api.put.mockResolvedValue({ + api.statuses.getAll.mockResolvedValue([{ id: 1, name: 'Open' }]); + api.workflows.getAll.mockResolvedValue([ + { + id: 7, + name: 'Company Workflow', + description: 'Workflow for the company project', + is_default: false, + }, + ]); + api.workflows.update.mockResolvedValue({ id: 7, name: 'Updated Workflow', description: 'Workflow for the company project', @@ -65,13 +82,14 @@ describe('WorkflowBuilder modal shortcuts', () => { const dialog = document.querySelector('[role="dialog"]'); expect(dialog).not.toBeNull(); - const nameInput = dialog.querySelector('input[type="text"]'); - const description = dialog.querySelector('textarea'); + await fireEvent.click(await screen.findByTestId('localized-object-canonical-toggle')); + const nameInput = screen.getByTestId('localized-object-canonical-name'); + const description = screen.getByTestId('localized-object-canonical-description'); await fireEvent.input(nameInput, { target: { value: 'Updated Workflow' } }); await fireEvent.keyDown(description, { key: 'Enter', metaKey: true }); await waitFor(() => { - expect(api.put).toHaveBeenCalledWith('/workflows/7', { + expect(api.workflows.update).toHaveBeenCalledWith(7, { name: 'Updated Workflow', description: 'Workflow for the company project', is_default: false, diff --git a/frontend/src/lib/mobile/MobileCreateDialog.test.js b/frontend/src/lib/mobile/MobileCreateDialog.test.js index 77d4db4..3b6b439 100644 --- a/frontend/src/lib/mobile/MobileCreateDialog.test.js +++ b/frontend/src/lib/mobile/MobileCreateDialog.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; // jsdom lacks the Web Animations API; Svelte 5 transitions call element.animate @@ -39,7 +39,7 @@ vi.mock('../api.js', () => ({ items: { create: vi.fn() }, itemTypes: { getAll: vi.fn() }, itemTemplates: { getAll: vi.fn().mockResolvedValue([]) }, - customFields: { getAll: vi.fn().mockResolvedValue({ data: [] }) }, + customFields: { getAll: vi.fn().mockResolvedValue([]) }, configurationSets: { getAll: vi.fn().mockResolvedValue({ configuration_sets: [] }), get: vi.fn().mockResolvedValue(null), @@ -64,14 +64,14 @@ import { api } from '../api.js'; import MobileCreateDialog from './MobileCreateDialog.svelte'; afterEach(() => { - document.body.innerHTML = ''; + cleanup(); }); beforeEach(() => { api.items.create.mockReset(); api.itemTypes.getAll.mockReset(); api.itemTemplates.getAll.mockReset().mockResolvedValue([]); - api.customFields.getAll.mockReset().mockResolvedValue({ data: [] }); + api.customFields.getAll.mockReset().mockResolvedValue([]); api.configurationSets.getAll.mockReset().mockResolvedValue({ configuration_sets: [] }); api.configurationSets.get.mockReset().mockResolvedValue(null); api.screens.getFields.mockReset().mockResolvedValue([]); @@ -156,9 +156,7 @@ describe('MobileCreateDialog — configured fields (WI-553)', () => { configuration_sets: [{ id: 20, is_default: true, workspace_ids: [1] }], }); api.configurationSets.get.mockResolvedValue({ id: 20, create_screen_id: 55 }); - api.customFields.getAll.mockResolvedValue({ - data: [{ id: 7, name: 'Risk', field_type: 'text' }], - }); + api.customFields.getAll.mockResolvedValue([{ id: 7, name: 'Risk', field_type: 'text' }]); api.screens.getFields.mockResolvedValue([ { id: 1, field_type: 'system', field_identifier: 'story_points', is_required: true }, { id: 2, field_type: 'custom', field_identifier: '7', is_required: true }, diff --git a/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js b/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js index 73d0dae..89d20ed 100644 --- a/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js +++ b/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js @@ -1,5 +1,19 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +const originalAnimate = Object.getOwnPropertyDescriptor(Element.prototype, 'animate'); +const originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView'); +const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver'); +afterAll(() => { + if (originalAnimate) Object.defineProperty(Element.prototype, 'animate', originalAnimate); + else delete Element.prototype.animate; + if (originalScrollIntoView) + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView); + else delete Element.prototype.scrollIntoView; + if (originalResizeObserver) + Object.defineProperty(globalThis, 'ResizeObserver', originalResizeObserver); + else delete globalThis.ResizeObserver; +}); vi.mock('../api.js', () => ({ api: { @@ -64,7 +78,7 @@ describe('WorkspaceLabelCombobox', () => { props: { workspaceId: 23, value: [] }, }); - await waitFor(() => expect(api.labels.getAll).toHaveBeenCalledWith()); + await waitFor(() => expect(api.labels.getAll).toHaveBeenCalledExactlyOnceWith(23)); }); test('creates a global label using the selected workspace authorization context', async () => { diff --git a/frontend/src/lib/portal/PortalHeader.test.js b/frontend/src/lib/portal/PortalHeader.test.js index 3d25aa8..81d16a0 100644 --- a/frontend/src/lib/portal/PortalHeader.test.js +++ b/frontend/src/lib/portal/PortalHeader.test.js @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/svelte'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../stores', async () => { const { writable } = await import('svelte/store'); @@ -22,7 +22,7 @@ vi.mock('../stores/portalAuth.svelte.js', async () => { }); vi.mock('../stores/portal.svelte.js', () => ({ - portalStore: { + portalCustomizationStore: { hasBackgroundImage: false, hasGradient: false, headerBackgroundStyle: '', @@ -32,7 +32,9 @@ vi.mock('../stores/portal.svelte.js', () => ({ showMainMenu: false, currentSlug: 'support', isDarkMode: false, - portalData: { title: 'Support' }, + editableTitle: 'Support', + isEditing: false, + effectiveLogoUrl: null, toggleTheme: vi.fn(), setShowMyRequests: vi.fn(), setShowMyApprovals: vi.fn(), @@ -53,6 +55,8 @@ import { authStore } from '../stores'; import { portalAuthStore } from '../stores/portalAuth.svelte.js'; import PortalHeader from './PortalHeader.svelte'; +afterEach(cleanup); + describe('PortalHeader', () => { beforeEach(() => { authStore.set({ isAuthenticated: false, currentUser: null }); diff --git a/frontend/src/lib/services/authenticatedShellUI.test.js b/frontend/src/lib/services/authenticatedShellUI.test.js index ba7426c..54bb4f8 100644 --- a/frontend/src/lib/services/authenticatedShellUI.test.js +++ b/frontend/src/lib/services/authenticatedShellUI.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; vi.mock('../api.js', () => ({ api: { @@ -56,7 +56,9 @@ import { workspaceDataStore } from '../stores/workspaceDataStore.svelte.js'; import { workspacesStore } from '../stores/workspaces.svelte.js'; import { hydrateAuthenticatedShellUI, + loadAuthenticatedShellUI, refreshAuthenticatedShellUI, + resetAuthenticatedShellUILoad, } from './authenticatedShellUI.js'; const snapshot = { @@ -74,13 +76,77 @@ const snapshot = { }; beforeEach(() => { + resetAuthenticatedShellUILoad(); vi.clearAllMocks(); - api.shellBootstrap.get.mockResolvedValue(snapshot); + api.shellBootstrap.get.mockReset().mockResolvedValue(snapshot); api.themes.getActive.mockResolvedValue({ id: 9, name: 'Night' }); workspacesStore.reload.mockResolvedValue([]); workspaceDataStore.refresh.mockResolvedValue(); }); +afterEach(() => resetAuthenticatedShellUILoad()); + +describe('authenticated shell load lifecycle', () => { + test('shares an in-flight request and reuses the settled snapshot for one audience', async () => { + let resolve; + api.shellBootstrap.get.mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }) + ); + const first = loadAuthenticatedShellUI(1); + const second = loadAuthenticatedShellUI(1); + expect(second).toBe(first); + expect(api.shellBootstrap.get).toHaveBeenCalledOnce(); + expect(moduleSettings.hydrate).not.toHaveBeenCalled(); + resolve(snapshot); + await expect(first).resolves.toBe(true); + await expect(second).resolves.toBe(true); + await expect(loadAuthenticatedShellUI(1)).resolves.toBe(true); + expect(api.shellBootstrap.get).toHaveBeenCalledOnce(); + expect(moduleSettings.hydrate).toHaveBeenCalledExactlyOnceWith(snapshot.module_settings); + }); + + test('reloads for a new audience and clears the settled cache on reset', async () => { + await expect(loadAuthenticatedShellUI(1)).resolves.toBe(true); + await expect(loadAuthenticatedShellUI(2)).resolves.toBe(true); + expect(api.shellBootstrap.get).toHaveBeenCalledTimes(2); + resetAuthenticatedShellUILoad(); + await expect(loadAuthenticatedShellUI(2)).resolves.toBe(true); + expect(api.shellBootstrap.get).toHaveBeenCalledTimes(3); + expect(moduleSettings.hydrate).toHaveBeenCalledTimes(3); + }); + + test('does not hydrate a stale response after reset while a new audience is loading', async () => { + let resolveOld, resolveNew; + api.shellBootstrap.get + .mockReturnValueOnce( + new Promise((done) => { + resolveOld = done; + }) + ) + .mockReturnValueOnce( + new Promise((done) => { + resolveNew = done; + }) + ); + const oldRequest = loadAuthenticatedShellUI(1); + resetAuthenticatedShellUILoad(); + const newRequest = loadAuthenticatedShellUI(2); + resolveOld(snapshot); + await expect(oldRequest).resolves.toBe(false); + expect(moduleSettings.hydrate).not.toHaveBeenCalled(); + expect(capabilitiesStore.hydrate).not.toHaveBeenCalled(); + const latest = { ...snapshot, module_settings: { test_management_enabled: true } }; + resolveNew(latest); + await expect(newRequest).resolves.toBe(true); + await expect(loadAuthenticatedShellUI(2)).resolves.toBe(true); + expect(api.shellBootstrap.get).toHaveBeenCalledTimes(2); + expect(moduleSettings.hydrate).toHaveBeenCalledExactlyOnceWith(latest.module_settings); + expect(capabilitiesStore.hydrate).toHaveBeenCalledExactlyOnceWith(latest.features); + }); +}); + describe('authenticated shell UI refresh', () => { test('hydrates every shared shell capability from one snapshot', () => { expect(hydrateAuthenticatedShellUI(snapshot)).toBe(true); diff --git a/frontend/src/lib/settings/ItemTypeManager.test.js b/frontend/src/lib/settings/ItemTypeManager.test.js index bd2f62f..a653922 100644 --- a/frontend/src/lib/settings/ItemTypeManager.test.js +++ b/frontend/src/lib/settings/ItemTypeManager.test.js @@ -1,97 +1,102 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalAnimate = Object.getOwnPropertyDescriptor(Element.prototype, 'animate'); +afterEach(cleanup); +afterAll(() => { + if (originalAnimate) Object.defineProperty(Element.prototype, 'animate', originalAnimate); + else delete Element.prototype.animate; +}); // jsdom does not implement the Web Animations API. Svelte 5 transitions // call element.animate when the create modal opens. beforeAll(() => { - if (!Element.prototype.animate) { - Element.prototype.animate = () => ({ - finished: Promise.resolve(), - cancel: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - play: () => {}, - pause: () => {}, - }); - } + if (!Element.prototype.animate) { + Element.prototype.animate = () => ({ + finished: Promise.resolve(), + cancel: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + play: () => {}, + pause: () => {}, + }); + } }); -vi.mock("../api.js", () => ({ - api: { - itemTypes: { - getAll: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, - hierarchyLevels: { - getAll: vi.fn(), - }, - }, +vi.mock('../api.js', () => ({ + api: { + itemTypes: { + getAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + hierarchyLevels: { + getAll: vi.fn(), + }, + }, })); -vi.mock("../stores/i18n.svelte.js", () => ({ - t: (key) => { - const messages = { - "settings.itemTypes.failedToSave": "Failed to save item type:", - }; - return messages[key] ?? key; - }, +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key) => { + const messages = { + 'settings.itemTypes.failedToSave': 'Failed to save item type:', + }; + return messages[key] ?? key; + }, })); -vi.mock("../stores/toasts.svelte.js", () => ({ - errorToast: vi.fn(), +vi.mock('../stores/toasts.svelte.js', () => ({ + errorToast: vi.fn(), })); -vi.mock("../composables/useConfirm.js", () => ({ - confirm: vi.fn(), +vi.mock('../composables/useConfirm.js', () => ({ + confirm: vi.fn(), })); -import { api } from "../api.js"; -import { errorToast } from "../stores/toasts.svelte.js"; -import ItemTypeManager from "./ItemTypeManager.svelte"; +import { api } from '../api.js'; +import { errorToast } from '../stores/toasts.svelte.js'; +import ItemTypeManager from './ItemTypeManager.svelte'; -describe("ItemTypeManager save errors", () => { - beforeEach(() => { - vi.clearAllMocks(); - api.itemTypes.getAll.mockResolvedValue([]); - api.hierarchyLevels.getAll.mockResolvedValue([{ level: 3, name: "Task" }]); - }); +describe('ItemTypeManager save errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + api.itemTypes.getAll.mockResolvedValue([]); + api.hierarchyLevels.getAll.mockResolvedValue([{ level: 3, name: 'Task' }]); + }); - it("shows a toast above the open create modal when saving fails", async () => { - api.itemTypes.create.mockRejectedValue( - new Error("Item type with this name already exists"), - ); + it('shows a toast above the open create modal when saving fails', async () => { + api.itemTypes.create.mockRejectedValue(new Error('Item type with this name already exists')); - render(ItemTypeManager); + render(ItemTypeManager); - const addButton = await screen.findByTestId("item-type-add"); - await waitFor(() => expect(addButton).not.toBeDisabled()); - await fireEvent.click(addButton); - await fireEvent.input(document.querySelector("#name"), { - target: { value: "Task" }, - }); - await fireEvent.click(screen.getByTestId("dialog-confirm")); + const addButton = await screen.findByTestId('item-type-add'); + await waitFor(() => expect(addButton).not.toBeDisabled()); + await fireEvent.click(addButton); + await fireEvent.input(document.querySelector('#name'), { + target: { value: 'Task' }, + }); + await fireEvent.click(screen.getByTestId('dialog-confirm')); - await waitFor(() => { - expect(errorToast).toHaveBeenCalledWith( - "Item type with this name already exists", - ); - }); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(document.querySelector(".error")).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + 'Failed to save item type: Item type with this name already exists' + ); + }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(document.querySelector('.error')).not.toBeInTheDocument(); + }); - it("shows missing-name validation in a toast without closing the modal", async () => { - render(ItemTypeManager); + it('shows missing-name validation in a toast without closing the modal', async () => { + render(ItemTypeManager); - const addButton = await screen.findByTestId("item-type-add"); - await waitFor(() => expect(addButton).not.toBeDisabled()); - await fireEvent.click(addButton); - await fireEvent.click(screen.getByTestId("dialog-confirm")); + const addButton = await screen.findByTestId('item-type-add'); + await waitFor(() => expect(addButton).not.toBeDisabled()); + await fireEvent.click(addButton); + await fireEvent.click(screen.getByTestId('dialog-confirm')); - expect(errorToast).toHaveBeenCalledWith("settings.itemTypes.nameRequired"); - expect(api.itemTypes.create).not.toHaveBeenCalled(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); + expect(errorToast).toHaveBeenCalledWith('settings.itemTypes.nameRequired'); + expect(api.itemTypes.create).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/settings/UpdateStatePreservation.test.js b/frontend/src/lib/settings/UpdateStatePreservation.test.js index b7318d9..72cd7f6 100644 --- a/frontend/src/lib/settings/UpdateStatePreservation.test.js +++ b/frontend/src/lib/settings/UpdateStatePreservation.test.js @@ -1,5 +1,12 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalAnimate = Object.getOwnPropertyDescriptor(Element.prototype, 'animate'); +afterEach(cleanup); +afterAll(() => { + if (originalAnimate) Object.defineProperty(Element.prototype, 'animate', originalAnimate); + else delete Element.prototype.animate; +}); beforeAll(() => { if (!Element.prototype.animate) { @@ -16,9 +23,9 @@ beforeAll(() => { vi.mock('../api.js', () => ({ api: { - getUsers: vi.fn(), + getAdminUsers: vi.fn(), groups: { - getAll: vi.fn(), + getAdminAll: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -52,14 +59,14 @@ async function openRowEdit(rowText, editText) { describe('settings update state preservation', () => { beforeEach(() => { vi.clearAllMocks(); - api.getUsers.mockResolvedValue([]); + api.getAdminUsers.mockResolvedValue([]); api.groups.update.mockResolvedValue({}); api.notificationSettings.getAvailableEvents.mockResolvedValue([]); api.notificationSettings.update.mockResolvedValue({}); }); it('keeps an inactive group inactive when editing its details', async () => { - api.groups.getAll.mockResolvedValue([ + api.groups.getAdminAll.mockResolvedValue([ { id: 41, name: 'Inactive Group', diff --git a/frontend/src/lib/settings/customFieldsData.test.js b/frontend/src/lib/settings/customFieldsData.test.js index 42d3dbb..3bbc207 100644 --- a/frontend/src/lib/settings/customFieldsData.test.js +++ b/frontend/src/lib/settings/customFieldsData.test.js @@ -39,9 +39,9 @@ describe('custom fields screen request graph', () => { it('loads every screen assignment with two bounded requests', async () => { const apiClient = { customFields: { - getAll: vi.fn().mockResolvedValue({ - data: [{ id: 7 }], - index_counts: { items: { current: 2, max: 20 }, assets: { current: 1, max: 20 } }, + getOverview: vi.fn().mockResolvedValue({ + customFields: [{ id: 7 }], + indexCounts: { items: { current: 2, max: 20 }, assets: { current: 1, max: 20 } }, }), }, screens: { @@ -52,7 +52,7 @@ describe('custom fields screen request graph', () => { const loading = loadCustomFieldsOverview(apiClient); - expect(apiClient.customFields.getAll).toHaveBeenCalledOnce(); + expect(apiClient.customFields.getOverview).toHaveBeenCalledOnce(); expect(apiClient.screens.getAllWithFields).toHaveBeenCalledOnce(); expect(apiClient.screens.getFields).not.toHaveBeenCalled(); const overview = await loading; @@ -67,9 +67,9 @@ describe('custom fields screen request graph', () => { it('preserves the custom field list when screen metadata fails to load', async () => { const apiClient = { customFields: { - getAll: vi.fn().mockResolvedValue({ - data: [{ id: 7 }, { id: 8 }], - index_counts: { items: { current: 0, max: 20 }, assets: { current: 0, max: 20 } }, + getOverview: vi.fn().mockResolvedValue({ + customFields: [{ id: 7 }, { id: 8 }], + indexCounts: { items: { current: 0, max: 20 }, assets: { current: 0, max: 20 } }, }), }, screens: { @@ -82,4 +82,15 @@ describe('custom fields screen request graph', () => { expect(overview.customFields).toEqual([{ id: 7 }, { id: 8 }]); expect(overview.screens).toEqual([]); }); + + it('propagates a custom-field overview failure instead of presenting an empty success', async () => { + const failure = new Error('Overview unavailable'); + const apiClient = { + customFields: { getOverview: vi.fn().mockRejectedValue(failure) }, + screens: { getAllWithFields: vi.fn().mockResolvedValue([]) }, + }; + await expect(loadCustomFieldsOverview(apiClient)).rejects.toBe(failure); + expect(apiClient.customFields.getOverview).toHaveBeenCalledOnce(); + expect(apiClient.screens.getAllWithFields).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/lib/stores/backlogStore.test.js b/frontend/src/lib/stores/backlogStore.test.js index 94f6c60..e4bdbb0 100644 --- a/frontend/src/lib/stores/backlogStore.test.js +++ b/frontend/src/lib/stores/backlogStore.test.js @@ -29,9 +29,9 @@ describe('BacklogStore request lifecycle', () => { const firstLoad = backlogStore.load(1); const secondLoad = backlogStore.load(2); - second.resolve({ pagination: { total: 22 } }); + second.resolve({ data: [], pagination: { total_items: 22 } }); await secondLoad; - first.resolve({ pagination: { total: 11 } }); + first.resolve({ data: [], pagination: { total_items: 11 } }); await firstLoad; expect(backlogStore.workspaceId).toBe(2); @@ -44,7 +44,7 @@ describe('BacklogStore request lifecycle', () => { const load = backlogStore.load(1); backlogStore.setCount(1, 7); - pending.resolve({ pagination: { total: 3 } }); + pending.resolve({ data: [], pagination: { total_items: 3 } }); await load; expect(backlogStore.count).toBe(7); diff --git a/frontend/src/lib/stores/collectionContext.test.js b/frontend/src/lib/stores/collectionContext.test.js index 61ef724..7b8973e 100644 --- a/frontend/src/lib/stores/collectionContext.test.js +++ b/frontend/src/lib/stores/collectionContext.test.js @@ -1,4 +1,4 @@ -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ routeSubscriber: null, @@ -50,13 +50,14 @@ function itemResult(options) { return { items: [{ id: page, status_id: 1 }], collectionName: 'Test board', - pagination: { page, limit: options.limit, total: 2, total_pages: 2 }, + pagination: { page, page_size: options.limit, total_items: 2, total_pages: 2 }, sortableFields: [], watermark: 1, }; } describe('CollectionStore board ordering', () => { + beforeEach(() => vi.clearAllMocks()); afterAll(() => collectionStore.destroy()); afterEach(() => vi.restoreAllMocks()); @@ -64,7 +65,7 @@ describe('CollectionStore board ordering', () => { mocks.fetchCollectionItems.mockClear(); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, watermark: 4, }); mocks.getBoardConfigurationBootstrap.mockResolvedValue({ @@ -83,7 +84,7 @@ describe('CollectionStore board ordering', () => { status_id: 1, })), collectionName: 'Split board', - pagination: { page: 1, limit: 1000, total: 38, total_pages: 1 }, + pagination: { page: 1, page_size: 1000, total_items: 38, total_pages: 1 }, watermark: 4, }); } @@ -96,7 +97,7 @@ describe('CollectionStore board ordering', () => { status_id: 2, })), collectionName: 'Split board', - pagination: { page: options.page, limit: 100, total: 105, total_pages: 2 }, + pagination: { page: options.page, page_size: 100, total_items: 105, total_pages: 2 }, watermark: 4, }); } @@ -129,7 +130,7 @@ describe('CollectionStore board ordering', () => { mocks.fetchCollectionItems.mockClear(); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, watermark: 5, }); mocks.getBoardConfigurationBootstrap.mockResolvedValue({ @@ -149,7 +150,7 @@ describe('CollectionStore board ordering', () => { return Promise.resolve({ items: [{ id: 1, status_id: 1 }], collectionName: 'Age-trimmed board', - pagination: { page: 1, limit: 1000, total: 1, total_pages: 1 }, + pagination: { page: 1, page_size: 1000, total_items: 1, total_pages: 1 }, watermark: 5, }); } @@ -157,7 +158,7 @@ describe('CollectionStore board ordering', () => { return Promise.resolve({ items: [{ id: 100 + options.page, status_id: 2 }], collectionName: 'Age-trimmed board', - pagination: { page: options.page, limit: 100, total: 2, total_pages: 2 }, + pagination: { page: options.page, page_size: 100, total_items: 2, total_pages: 2 }, watermark: 5, }); } @@ -193,7 +194,7 @@ describe('CollectionStore board ordering', () => { mocks.fetchCollectionItems.mockClear(); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, watermark: 6, }); const collection = { id: 88, name: 'Scoped search collection' }; @@ -217,7 +218,7 @@ describe('CollectionStore board ordering', () => { return Promise.resolve({ items: [{ id: 900 + options.page, status_id: 2 }], collectionName: collection.name, - pagination: { page: options.page, limit: 100, total: 2, total_pages: 2 }, + pagination: { page: options.page, page_size: 100, total_items: 2, total_pages: 2 }, watermark: 6, }); } @@ -225,7 +226,7 @@ describe('CollectionStore board ordering', () => { return Promise.resolve({ items: [{ id: 1, status_id: 1 }], collectionName: collection.name, - pagination: { page: 1, limit: 1000, total: 1, total_pages: 1 }, + pagination: { page: 1, page_size: 1000, total_items: 1, total_pages: 1 }, watermark: 6, }); } @@ -233,7 +234,7 @@ describe('CollectionStore board ordering', () => { return Promise.resolve({ items: [{ id: 2, status_id: 2 }], collectionName: collection.name, - pagination: { page: 1, limit: 50, total: 55, total_pages: 2 }, + pagination: { page: 1, page_size: 50, total_items: 55, total_pages: 2 }, watermark: 6, }); } @@ -277,66 +278,49 @@ describe('CollectionStore board ordering', () => { expect(collectionStore.boardSearchHasMore).toBe(false); }); - it('applies Bubble Mode before pagination on loads, refreshes, and later pages', async () => { + it('applies Bubble Mode to initial and background pages on loads and refreshes', async () => { mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => Promise.resolve(itemResult(options)) ); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, }); mocks.fetchCollectionItemChanges.mockResolvedValue({ watermark: 1 }); mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + async function expectBoardPages(workspaceId, orderBy, direction) { + await vi.waitFor(() => { + expect(collectionStore.loading).toBe(false); + expect(collectionStore.boardBackgroundLoading).toBe(false); + expect(mocks.fetchCollectionItems).toHaveBeenCalledTimes(2); + }); + for (const page of [1, 2]) { + expect(mocks.fetchCollectionItems).toHaveBeenNthCalledWith( + page, + workspaceId, + null, + expect.objectContaining({ + page, + limit: 1000, + order_by: orderBy, + sort_direction: direction, + }) + ); + } + expect(collectionStore.items.map((item) => item.id)).toEqual([1, 2]); + } + mocks.routeSubscriber({ view: 'workspace-board', params: { id: '42' } }); - await vi.waitFor(() => expect(mocks.fetchCollectionItems).toHaveBeenCalledTimes(1)); - expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( - '42', - null, - expect.objectContaining({ - page: 1, - limit: 100, - order_by: 'frac_index', - sort_direction: 'asc', - }) - ); + await expectBoardPages('42', 'frac_index', 'asc'); mocks.fetchCollectionItems.mockClear(); collectionStore.setBoardSortMode('bubble'); - await vi.waitFor(() => expect(mocks.fetchCollectionItems).toHaveBeenCalledTimes(1)); - expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( - '42', - null, - expect.objectContaining({ - page: 1, - order_by: 'last_active_at', - sort_direction: 'desc', - }) - ); - - mocks.fetchCollectionItems.mockClear(); - await collectionStore.loadMoreItems(); - expect(mocks.fetchCollectionItems).toHaveBeenCalledWith( - '42', - null, - expect.objectContaining({ - page: 2, - order_by: 'last_active_at', - sort_direction: 'desc', - }) - ); + await expectBoardPages('42', 'last_active_at', 'desc'); mocks.fetchCollectionItems.mockClear(); await collectionStore.refresh(); - expect(mocks.fetchCollectionItems).toHaveBeenCalledWith( - '42', - null, - expect.objectContaining({ - page: 1, - order_by: 'last_active_at', - sort_direction: 'desc', - }) - ); + await expectBoardPages('42', 'last_active_at', 'desc'); mocks.fetchCollectionItems.mockClear(); mocks.routeSubscriber({ view: 'workspace-board', params: { id: '43' } }); @@ -345,29 +329,11 @@ describe('CollectionStore board ordering', () => { expect(collectionStore.items).toEqual([]); expect(collectionStore.backlogItems).toEqual([]); expect(collectionStore.itemsPagination).toBeNull(); - await vi.waitFor(() => expect(mocks.fetchCollectionItems).toHaveBeenCalledTimes(1)); - expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( - '43', - null, - expect.objectContaining({ - page: 1, - order_by: 'last_active_at', - sort_direction: 'desc', - }) - ); + await expectBoardPages('43', 'last_active_at', 'desc'); mocks.fetchCollectionItems.mockClear(); collectionStore.setBoardSortMode('rank'); - await vi.waitFor(() => expect(mocks.fetchCollectionItems).toHaveBeenCalledTimes(1)); - expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( - '43', - null, - expect.objectContaining({ - page: 1, - order_by: 'frac_index', - sort_direction: 'asc', - }) - ); + await expectBoardPages('43', 'frac_index', 'asc'); }); it('does not log expected connectivity failures from delta polling', async () => { @@ -376,7 +342,7 @@ describe('CollectionStore board ordering', () => { ); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, }); mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); @@ -403,7 +369,7 @@ describe('CollectionStore board ordering', () => { mocks.fetchCollectionBacklog.mockResolvedValue({ items: [{ id: 2, status_id: 1 }], collectionName: 'Backlog', - pagination: { page: 1, limit: 100, total: 1, total_pages: 1 }, + pagination: { page: 1, page_size: 100, total_items: 1, total_pages: 1 }, watermark: 3, }); @@ -429,7 +395,7 @@ describe('CollectionStore board ordering', () => { ); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], - pagination: { page: 1, limit: 100, total: 0, total_pages: 0 }, + pagination: { page: 1, page_size: 100, total_items: 0, total_pages: 0 }, watermark: 7, }); mocks.fetchCollectionItemChanges.mockResolvedValue({ diff --git a/frontend/src/lib/stores/collectionEditorOptions.test.js b/frontend/src/lib/stores/collectionEditorOptions.test.js index f2eea87..b590684 100644 --- a/frontend/src/lib/stores/collectionEditorOptions.test.js +++ b/frontend/src/lib/stores/collectionEditorOptions.test.js @@ -105,7 +105,10 @@ describe('CollectionEditorOptionsStore', () => { }); it('single-flights asset options by workspace, set, filter, and search', async () => { - api.assets.getAll.mockResolvedValue({ assets: [{ id: 3 }], total: 1 }); + api.assets.getAll.mockResolvedValue({ + data: [{ id: 3 }], + pagination: { page: 1, page_size: 50, total_items: 1, total_pages: 1 }, + }); const [first, second] = await Promise.all([ store.loadAssets(11, 4, 'status = active', 'lap'), diff --git a/frontend/src/lib/stores/itemDetailStore.test.js b/frontend/src/lib/stores/itemDetailStore.test.js index 06361eb..a6858c5 100644 --- a/frontend/src/lib/stores/itemDetailStore.test.js +++ b/frontend/src/lib/stores/itemDetailStore.test.js @@ -435,7 +435,10 @@ describe('itemDetailStore optional item data', () => { it('loads time-modal-only picker data once', async () => { api.customerOrganisations.getAll.mockResolvedValue([{ id: 1 }]); - api.items.getAll.mockResolvedValue({ items: [{ id: 2 }] }); + api.items.getAll.mockResolvedValue({ + data: [{ id: 2 }], + pagination: { page: 1, page_size: 100, total: 1, has_more: false }, + }); api.workspaces.getAll.mockResolvedValue([{ id: 3 }]); await Promise.all([itemDetailStore.loadTimeModalData(), itemDetailStore.loadTimeModalData()]); @@ -472,6 +475,34 @@ describe('itemDetailStore optional item data', () => { expect(api.getDiagrams).toHaveBeenCalledTimes(1); }); + it('marks the current item deleted when its diagrams return 404', async () => { + api.getDiagrams.mockRejectedValue({ status: 404 }); + await itemDetailStore.loadDiagrams(); + expect(itemDetailStore.item).toBeNull(); + expect(itemDetailStore.notFound).toBe(true); + }); + + it.each([false, true])( + 'ignores a stale diagram 404 after switching items (reset=%s)', + async (reset) => { + let rejectDiagrams; + api.getDiagrams.mockReturnValue( + new Promise((_, reject) => { + rejectDiagrams = reject; + }) + ); + const pending = itemDetailStore.loadDiagrams(); + if (reset) itemDetailStore.reset(); + itemDetailStore.item = { id: 43, title: 'Next item' }; + itemDetailStore.diagrams = [{ id: 9, name: 'Next diagram' }]; + rejectDiagrams({ status: 404 }); + await pending; + expect(itemDetailStore.item).toEqual({ id: 43, title: 'Next item' }); + expect(itemDetailStore.diagrams).toEqual([{ id: 9, name: 'Next diagram' }]); + expect(itemDetailStore.notFound).toBe(false); + } + ); + it('refreshes links without reloading the complete item', async () => { api.links.getForItem.mockResolvedValue({ outgoing: [{ id: 1 }], diff --git a/frontend/src/lib/stores/notifications.test.js b/frontend/src/lib/stores/notifications.test.js index 99a3b78..49b1200 100644 --- a/frontend/src/lib/stores/notifications.test.js +++ b/frontend/src/lib/stores/notifications.test.js @@ -164,7 +164,7 @@ describe('notificationActions.markItemAsRead', () => { expect(errSpy).toHaveBeenCalled(); }); - test('no-op when there are no unread notifications for the item', async () => { + test('acknowledges the item on the server even when its local notifications are read', async () => { notifications.set([ { id: 1, read: true, actionUrl: '/workspaces/2/items/42' }, { id: 2, read: false, actionUrl: '/workspaces/2/items/99' }, @@ -172,7 +172,7 @@ describe('notificationActions.markItemAsRead', () => { await notificationActions.markItemAsRead(42); - expect(api.notifications.markItemAsRead).not.toHaveBeenCalled(); + expect(api.notifications.markItemAsRead).toHaveBeenCalledExactlyOnceWith(42); expect(get(notifications)).toEqual([ { id: 1, read: true, actionUrl: '/workspaces/2/items/42' }, { id: 2, read: false, actionUrl: '/workspaces/2/items/99' }, @@ -280,12 +280,13 @@ describe('notificationActions.markAllAsRead', () => { expect(get(notifications).map((n) => n.read)).toEqual([false, false]); }); - test('does not call the API when all notifications are already read', async () => { + test('acknowledges the server inbox even when the local snapshot is already read', async () => { notifications.set([{ id: 1, read: true }]); await notificationActions.markAllAsRead(); - expect(api.notifications.markAllAsRead).not.toHaveBeenCalled(); + expect(api.notifications.markAllAsRead).toHaveBeenCalledOnce(); + expect(get(notifications)).toEqual([{ id: 1, read: true }]); }); }); diff --git a/frontend/src/lib/utils/timeUtils.test.js b/frontend/src/lib/utils/timeUtils.test.js index 077b928..ccc1438 100644 --- a/frontend/src/lib/utils/timeUtils.test.js +++ b/frontend/src/lib/utils/timeUtils.test.js @@ -108,14 +108,14 @@ describe('minutesBetweenTimes', () => { expect(minutesBetweenTimes('09:15', '09:45')).toBe(30); }); - test('returns 0 when end equals start', () => { - expect(minutesBetweenTimes('09:00', '09:00')).toBe(0); + test('treats equal clocks as the full 24-hour interval', () => { + expect(minutesBetweenTimes('09:00', '09:00')).toBe(1440); }); - test('returns 0 when end is before start (no wrap-around)', () => { - // Intentional: the helper guards against negative durations rather than - // implying overnight rollover. - expect(minutesBetweenTimes('10:00', '09:00')).toBe(0); + test('carries an earlier end clock into the following day', () => { + expect(minutesBetweenTimes('10:00', '09:00')).toBe(1380); + expect(minutesBetweenTimes('23:00', '01:00')).toBe(120); + expect(minutesBetweenTimes('23:59', '00:00')).toBe(1); }); }); diff --git a/frontend/src/lib/widgets/WorkspaceWelcomeWidgets.test.js b/frontend/src/lib/widgets/WorkspaceWelcomeWidgets.test.js index dc03e24..9eb885e 100644 --- a/frontend/src/lib/widgets/WorkspaceWelcomeWidgets.test.js +++ b/frontend/src/lib/widgets/WorkspaceWelcomeWidgets.test.js @@ -47,7 +47,7 @@ import OverdueItemsWidget from './OverdueItemsWidget.svelte'; import UpcomingDeadlinesWidget from './UpcomingDeadlinesWidget.svelte'; beforeEach(() => { - mocks.getItems.mockResolvedValue({ items: [] }); + mocks.getItems.mockResolvedValue({ data: [] }); mocks.getIterations.mockResolvedValue([]); mocks.getMilestones.mockResolvedValue([]); mocks.getStatuses.mockResolvedValue([{ id: 9, category_name: 'Closed', is_completed: true }]); @@ -56,7 +56,7 @@ beforeEach(() => { describe('workspace dashboard item filters', () => { it('requests only incomplete items for My Tasks and renders the result', async () => { mocks.getItems.mockResolvedValue({ - items: [ + data: [ { id: 21, title: 'Prepare 0.8.7', diff --git a/frontend/src/lib/widgets/dashboard/SavedSearchWidget.test.js b/frontend/src/lib/widgets/dashboard/SavedSearchWidget.test.js index 6af1841..176ace6 100644 --- a/frontend/src/lib/widgets/dashboard/SavedSearchWidget.test.js +++ b/frontend/src/lib/widgets/dashboard/SavedSearchWidget.test.js @@ -50,7 +50,7 @@ describe('SavedSearchWidget', () => { })); mocks.getCollections.mockResolvedValue([]); mocks.getWorkspaces.mockResolvedValue([]); - mocks.getItems.mockResolvedValue({ items: [] }); + mocks.getItems.mockResolvedValue({ data: [] }); }); afterAll(() => { @@ -64,7 +64,7 @@ describe('SavedSearchWidget', () => { it('loads the selected collection and renders dashboard item rows', async () => { mocks.getCollections.mockResolvedValue([{ id: 9, name: 'Release queue', workspace_id: 7 }]); mocks.getItems.mockResolvedValue({ - items: [ + data: [ { id: 21, title: 'Ship saved search widget', diff --git a/frontend/src/lib/widgets/dashboard/taskWidgetState.test.js b/frontend/src/lib/widgets/dashboard/taskWidgetState.test.js index 416db82..4410474 100644 --- a/frontend/src/lib/widgets/dashboard/taskWidgetState.test.js +++ b/frontend/src/lib/widgets/dashboard/taskWidgetState.test.js @@ -85,28 +85,50 @@ describe('normalizeTaskResponse', () => { ]; test('sorts by due date (earliest first, nulls last)', () => { - const result = normalizeTaskResponse(items); + const result = normalizeTaskResponse({ data: items }); expect(result.map((t) => t.id)).toEqual([3, 1, 4, 2]); }); test('respects numeric maxItems cap', () => { - const result = normalizeTaskResponse(items, 2); + const result = normalizeTaskResponse({ data: items }, 2); expect(result).toHaveLength(2); expect(result[0].id).toBe(3); // earliest due date }); test("'all' returns every item", () => { - const result = normalizeTaskResponse(items, 'all'); + const result = normalizeTaskResponse({ data: items }, 'all'); expect(result).toHaveLength(4); }); - test('handles wrapped { items: [...] } responses', () => { - const result = normalizeTaskResponse({ items }); + test('handles v2 data and pagination without rendering metadata', () => { + const result = normalizeTaskResponse({ + data: items, + pagination: { page: 1, page_size: 50, total_items: 4, total_pages: 1 }, + }); expect(result).toHaveLength(4); }); test('filters out entries without an id', () => { - const result = normalizeTaskResponse([{ title: 'no id' }, ...items]); + const result = normalizeTaskResponse({ data: [null, { title: 'no id' }, ...items] }); expect(result).toHaveLength(4); }); + + test('normalizes due dates without mutating the source objects or ordering', () => { + const source = Object.freeze(items.map((item) => Object.freeze({ ...item }))); + const result = normalizeTaskResponse({ data: source }); + expect(result.map((item) => item.dueDate)).toEqual([ + '2026-06-01T00:00:00Z', + '2026-06-05T00:00:00Z', + '2026-06-10T00:00:00Z', + null, + ]); + expect(source.map((item) => item.id)).toEqual([1, 2, 3, 4]); + expect(source.every((item) => !Object.hasOwn(item, 'dueDate'))).toBe(true); + }); + + test('returns no rows for empty or absent v2 data', () => { + for (const response of [null, undefined, {}, { data: [] }]) { + expect(normalizeTaskResponse(response)).toEqual([]); + } + }); }); diff --git a/internal/auth/scope_catalog_test.go b/internal/auth/scope_catalog_test.go index 2ba7b70..033337d 100644 --- a/internal/auth/scope_catalog_test.go +++ b/internal/auth/scope_catalog_test.go @@ -87,7 +87,7 @@ func TestDefaultAgentScopesExcludesDestructiveScopes(t *testing.T) { for _, unwanted := range []string{ auth.ScopeItemsDelete, auth.ScopeWorkspacesDelete, auth.ScopeTimeDelete, auth.ScopeAssetsDelete, auth.ScopeMilestonesDelete, auth.ScopeIterationsDelete, - auth.ScopeProjectsDelete, + "projects:delete", // Retired scopes must not reappear in the default grant. } { if slices.Contains(auth.DefaultAgentScopes, unwanted) { t.Errorf("DefaultAgentScopes must not grant %q by default", unwanted) diff --git a/internal/auth/scopes_test.go b/internal/auth/scopes_test.go index 78de920..6ca05e1 100644 --- a/internal/auth/scopes_test.go +++ b/internal/auth/scopes_test.go @@ -66,7 +66,7 @@ func TestValidateAgentScopes_RejectsItemsDelete(t *testing.T) { } func TestValidateAgentScopes_RejectsPlanningWrites(t *testing.T) { - for _, s := range []string{ScopeMilestonesWrite, ScopeIterationsWrite, ScopeProjectsWrite} { + for _, s := range []string{ScopeMilestonesWrite, ScopeIterationsWrite, "projects:write"} { if err := ValidateAgentScopes([]string{s}); err == nil { t.Errorf("planning :write scope %q must be rejected for agent tokens", s) } diff --git a/internal/email/processor_core_regression_test.go b/internal/email/processor_core_regression_test.go index 355d184..85f968a 100644 --- a/internal/email/processor_core_regression_test.go +++ b/internal/email/processor_core_regression_test.go @@ -64,12 +64,12 @@ func TestTrackingPreclaimRecoversOnlyStaleIncompleteClaims(t *testing.T) { ctx := context.Background() claimed, err := processor.preclaimTracking(ctx, message, channelID, message.MessageID) - if err != nil || !claimed { - t.Fatalf("first preclaim = (%v, %v), want (true, nil)", claimed, err) + if err != nil || claimed != trackingClaimAcquired { + t.Fatalf("first preclaim = (%v, %v), want (acquired, nil)", claimed, err) } claimed, err = processor.preclaimTracking(ctx, message, channelID, message.MessageID) - if err != nil || claimed { - t.Fatalf("fresh duplicate preclaim = (%v, %v), want (false, nil)", claimed, err) + if err != nil || claimed != trackingClaimInProgress { + t.Fatalf("fresh duplicate preclaim = (%v, %v), want (in progress, nil)", claimed, err) } if _, err := db.ExecWrite(` @@ -79,8 +79,8 @@ func TestTrackingPreclaimRecoversOnlyStaleIncompleteClaims(t *testing.T) { t.Fatalf("age incomplete claim: %v", err) } claimed, err = processor.preclaimTracking(ctx, message, channelID, message.MessageID) - if err != nil || !claimed { - t.Fatalf("stale incomplete preclaim = (%v, %v), want (true, nil)", claimed, err) + if err != nil || claimed != trackingClaimAcquired { + t.Fatalf("stale incomplete preclaim = (%v, %v), want (acquired, nil)", claimed, err) } var workspaceID, itemID int @@ -100,8 +100,8 @@ func TestTrackingPreclaimRecoversOnlyStaleIncompleteClaims(t *testing.T) { t.Fatalf("complete claim: %v", err) } claimed, err = processor.preclaimTracking(ctx, message, channelID, message.MessageID) - if err != nil || claimed { - t.Fatalf("completed preclaim = (%v, %v), want (false, nil)", claimed, err) + if err != nil || claimed != trackingClaimCompleted { + t.Fatalf("completed preclaim = (%v, %v), want (completed, nil)", claimed, err) } } diff --git a/internal/handlers/comment_test.go b/internal/handlers/comment_test.go deleted file mode 100644 index 5b551fb..0000000 --- a/internal/handlers/comment_test.go +++ /dev/null @@ -1,588 +0,0 @@ -//go:build test - -package handlers - -import ( - "fmt" - "net/http" - "strings" - "testing" - - "windshift/internal/models" - "windshift/internal/services" - "windshift/internal/testutils" -) - -type commentFeedTestResponse struct { - Comments []models.Comment `json:"comments"` - HasMore bool `json:"has_more"` - Total int `json:"total"` -} - -// createCommentHandler creates a CommentHandler with test services -func createCommentHandler(t *testing.T, tdb *testutils.TestDB) *CommentHandler { - t.Helper() - permService, actTracker, notifService := createTestServices(t, *tdb) - h := NewCommentHandler(tdb.GetDatabase(), permService, actTracker, notifService) - // Comment writes go exclusively through CommentService now (WI-483); the - // handler no longer has a direct-SQL fallback. - h.SetCommentService(services.NewCommentService(tdb.GetDatabase())) - return h -} - -// createTestItemForComments creates an item in the test workspace and returns its ID -func createTestItemForComments(t *testing.T, tdb *testutils.TestDB, data testutils.TestDataSet) int { - t.Helper() - permService, actTracker, notifService := createTestServices(t, *tdb) - itemHandler := NewItemHandler(tdb.GetDatabase(), permService, actTracker, notifService) - - statusID := data.StatusID - priorityID := data.PriorityID - item := models.Item{ - WorkspaceID: data.WorkspaceID, - Title: "Item for Comments", - StatusID: &statusID, - PriorityID: &priorityID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/items", item) - rr := testutils.ExecuteAuthenticatedRequest(t, itemHandler.Create, req, nil) - rr.AssertStatusCode(http.StatusCreated) - - var created models.Item - rr.AssertJSONResponse(&created) - return created.ID -} - -func TestCommentHandler_CreateComment_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - body := map[string]interface{}{ - "content": "This is a test comment", - "author_id": data.UserID, - } - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - - rr.AssertStatusCode(http.StatusCreated). - AssertContentType("application/json") - - var comment models.Comment - rr.AssertJSONResponse(&comment) - - if comment.ID == 0 { - t.Error("Expected comment to have an ID") - } - if comment.ItemID != itemID { - t.Errorf("Expected item_id %d, got %d", itemID, comment.ItemID) - } - if comment.Content != "This is a test comment" { - t.Errorf("Expected content 'This is a test comment', got %q", comment.Content) - } -} - -// TestCommentHandler_CreateComment_RejectsOversizedBody pins the request-body -// cap: a body larger than the comment cap (content ceiling + JSON headroom) is -// rejected cleanly rather than streamed into memory or smuggled past the -// content sanitizer into the @mention agent-prompt path. -func TestCommentHandler_CreateComment_RejectsOversizedBody(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // commentRequestBodyMaxBytes is 256 KiB + 16 KiB; exceed it comfortably. - huge := strings.Repeat("A", (256*1024)+(64*1024)) - body := map[string]interface{}{"content": huge} - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - rr.AssertStatusCode(http.StatusBadRequest) -} - -func TestCommentHandler_CreateComment_ValidationErrors(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - tests := []struct { - name string - body map[string]interface{} - expectedErr string - }{ - { - name: "Empty content", - body: map[string]interface{}{"content": ""}, - expectedErr: "Content is required", - }, - { - name: "Whitespace-only content", - body: map[string]interface{}{"content": " "}, - expectedErr: "Content is required", - }, - // The handler intentionally ignores any "author_id" in the request body - // (bughunt2 Run 6 finding #2: author-spoofing). The author is always - // the authenticated caller, so the prior "Missing/Zero/Non-existent - // author_id" cases no longer apply — those bodies now succeed and - // attribute the comment to the caller. - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), tt.body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - - if rr.Code == http.StatusCreated { - t.Errorf("Expected error response, got 201 Created") - } - if !strings.Contains(rr.Body.String(), tt.expectedErr) { - t.Errorf("Expected body to contain %q, got %q", tt.expectedErr, rr.Body.String()) - } - }) - } -} - -func TestCommentHandler_CreateComment_InvalidItemID(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - body := map[string]interface{}{ - "content": "Hello", - "author_id": 1, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/items/abc/comments", body) - req.SetPathValue("id", "abc") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - - rr.AssertStatusCode(http.StatusBadRequest) -} - -func TestCommentHandler_CreateComment_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - body := map[string]interface{}{ - "content": "Hello", - "author_id": data.UserID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/items/99999/comments", body) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_GetComments_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create multiple comments - for i := 0; i < 3; i++ { - body := map[string]interface{}{ - "content": fmt.Sprintf("Comment %d", i+1), - "author_id": data.UserID, - } - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, req, nil) - } - - // Get comments - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/comments", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response commentFeedTestResponse - rr.AssertJSONResponse(&response) - - if len(response.Comments) != 3 { - t.Errorf("Expected 3 comments, got %d", len(response.Comments)) - } - if response.HasMore { - t.Error("Expected has_more=false") - } - if response.Total != 3 { - t.Errorf("Expected total=3, got %d", response.Total) - } - - // Verify author name is populated - for _, c := range response.Comments { - if c.AuthorName == "" { - t.Error("Expected author_name to be populated") - } - } -} - -func TestCommentHandler_GetComments_DefaultPageIsBounded(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - service := services.NewCommentService(tdb.GetDatabase()) - - for i := 1; i <= 26; i++ { - if _, err := service.Create(services.CreateCommentParams{ - ItemID: itemID, - AuthorID: data.UserID, - ActorUserID: data.UserID, - Content: fmt.Sprintf("Comment %02d", i), - }); err != nil { - t.Fatalf("create comment %d: %v", i, err) - } - } - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/comments", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, req, nil) - rr.AssertStatusCode(http.StatusOK) - - var response commentFeedTestResponse - rr.AssertJSONResponse(&response) - if len(response.Comments) != services.DefaultCommentFeedLimit { - t.Fatalf("comments = %d, want %d", len(response.Comments), services.DefaultCommentFeedLimit) - } - if !response.HasMore { - t.Error("has_more = false, want true") - } - if response.Total != 26 { - t.Errorf("total = %d, want 26", response.Total) - } -} - -func TestCommentHandler_GetComments_RejectsInvalidPagination(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - tests := []struct { - name string - query string - }{ - {name: "zero limit", query: "limit=0"}, - {name: "limit above maximum", query: "limit=101"}, - {name: "partial before cursor", query: "before=2026-07-27T12%3A00%3A00Z"}, - {name: "invalid since timestamp", query: "since=not-a-time&since_id=1"}, - {name: "both cursor directions", query: "before=2026-07-27T12%3A00%3A00Z&before_id=1&since=2026-07-27T12%3A00%3A00Z&since_id=1"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - req := testutils.CreateJSONRequest( - t, - "GET", - fmt.Sprintf("/api/items/%d/comments?%s", itemID, test.query), - nil, - ) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, req, nil) - rr.AssertStatusCode(http.StatusBadRequest) - }) - } -} - -func TestCommentHandler_GetComments_EmptyList(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/comments", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - var response commentFeedTestResponse - rr.AssertJSONResponse(&response) - if len(response.Comments) != 0 || response.HasMore || response.Total != 0 { - t.Errorf("Expected empty comment page, got %+v", response) - } -} - -func TestCommentHandler_GetComments_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/items/99999/comments", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_UpdateComment_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create a comment - createBody := map[string]interface{}{ - "content": "Original content", - "author_id": data.UserID, - } - createReq := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), createBody) - createReq.SetPathValue("id", testutils.IntToString(itemID)) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, createReq, nil) - - var created models.Comment - createRR.AssertJSONResponse(&created) - - // Update the comment - updateBody := map[string]interface{}{ - "content": "Updated content", - } - updateReq := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/comments/%d", created.ID), updateBody) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.UpdateComment, updateReq, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var updated models.Comment - rr.AssertJSONResponse(&updated) - - if updated.Content != "Updated content" { - t.Errorf("Expected content 'Updated content', got %q", updated.Content) - } - if updated.ID != created.ID { - t.Errorf("Expected comment ID %d, got %d", created.ID, updated.ID) - } -} - -func TestCommentHandler_UpdateComment_EmptyContent(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create a comment - createBody := map[string]interface{}{ - "content": "Original content", - "author_id": data.UserID, - } - createReq := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), createBody) - createReq.SetPathValue("id", testutils.IntToString(itemID)) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, createReq, nil) - - var created models.Comment - createRR.AssertJSONResponse(&created) - - // Try to update with empty content - updateBody := map[string]interface{}{ - "content": "", - } - updateReq := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/comments/%d", created.ID), updateBody) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.UpdateComment, updateReq, nil) - - rr.AssertStatusCode(http.StatusBadRequest) - if !strings.Contains(rr.Body.String(), "Content is required") { - t.Errorf("Expected 'Content is required' error, got %q", rr.Body.String()) - } -} - -func TestCommentHandler_UpdateComment_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - updateBody := map[string]interface{}{ - "content": "Updated content", - } - req := testutils.CreateJSONRequest(t, "PUT", "/api/comments/99999", updateBody) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.UpdateComment, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_UpdateComment_NotAuthor(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create a comment as user 1 - createBody := map[string]interface{}{ - "content": "Original content", - "author_id": data.UserID, - } - createReq := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), createBody) - createReq.SetPathValue("id", testutils.IntToString(itemID)) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, createReq, nil) - - var created models.Comment - createRR.AssertJSONResponse(&created) - - // Try to update as a different user (who doesn't have edit-others permission) - otherUser := testutils.TestUserWithID(999) - updateBody := map[string]interface{}{ - "content": "Hacked content", - } - updateReq := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/comments/%d", created.ID), updateBody) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.UpdateComment, updateReq, otherUser) - - // Per the security policy, a non-author without edit-others permission - // sees 404 rather than 403 to avoid leaking the comment's existence. - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_DeleteComment_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create a comment - createBody := map[string]interface{}{ - "content": "Comment to delete", - "author_id": data.UserID, - } - createReq := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), createBody) - createReq.SetPathValue("id", testutils.IntToString(itemID)) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, createReq, nil) - - var created models.Comment - createRR.AssertJSONResponse(&created) - - // Delete the comment - deleteReq := testutils.CreateJSONRequest(t, "DELETE", fmt.Sprintf("/api/comments/%d", created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.DeleteComment, deleteReq, nil) - - rr.AssertStatusCode(http.StatusNoContent) - - // Verify comment is gone by trying to get comments for the item - getReq := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/comments", itemID), nil) - getReq.SetPathValue("id", testutils.IntToString(itemID)) - getRR := testutils.ExecuteAuthenticatedRequest(t, handler.GetComments, getReq, nil) - - getRR.AssertStatusCode(http.StatusOK) - var response commentFeedTestResponse - getRR.AssertJSONResponse(&response) - if len(response.Comments) != 0 || response.Total != 0 { - t.Errorf("Expected no comments after deletion, got %+v", response) - } -} - -func TestCommentHandler_DeleteComment_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "DELETE", "/api/comments/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.DeleteComment, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_DeleteComment_NotAuthor(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // Create a comment as user 1 - createBody := map[string]interface{}{ - "content": "Comment by user 1", - "author_id": data.UserID, - } - createReq := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/comments", itemID), createBody) - createReq.SetPathValue("id", testutils.IntToString(itemID)) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateComment, createReq, nil) - - var created models.Comment - createRR.AssertJSONResponse(&created) - - // Try to delete as a different user - otherUser := testutils.TestUserWithID(999) - deleteReq := testutils.CreateJSONRequest(t, "DELETE", fmt.Sprintf("/api/comments/%d", created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.DeleteComment, deleteReq, otherUser) - - // Same fail-closed policy as the update path. - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestCommentHandler_InvalidID_Scenarios(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createCommentHandler(t, tdb) - - tests := []struct { - name string - method string - handler testutils.TestHandler - }{ - {"GetComments invalid ID", "GET", handler.GetComments}, - {"CreateComment invalid ID", "POST", handler.CreateComment}, - {"UpdateComment invalid ID", "PUT", handler.UpdateComment}, - {"DeleteComment invalid ID", "DELETE", handler.DeleteComment}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testutils.CreateJSONRequest(t, tt.method, "/api/comments/abc", map[string]interface{}{"content": "test", "author_id": 1}) - req.SetPathValue("id", "abc") - rr := testutils.ExecuteAuthenticatedRequest(t, tt.handler, req, nil) - rr.AssertStatusCode(http.StatusBadRequest) - }) - } -} diff --git a/internal/handlers/diagram_test.go b/internal/handlers/diagram_test.go deleted file mode 100644 index e568469..0000000 --- a/internal/handlers/diagram_test.go +++ /dev/null @@ -1,286 +0,0 @@ -//go:build test - -package handlers - -import ( - "fmt" - "net/http" - "testing" - - "windshift/internal/models" - "windshift/internal/repository" - "windshift/internal/testutils" -) - -// createDiagramHandler creates a DiagramHandler with test services -func createDiagramHandler(t *testing.T, tdb *testutils.TestDB) *DiagramHandler { - t.Helper() - permService, _, _ := createTestServices(t, *tdb) - db := tdb.GetDatabase() - return NewDiagramHandler( - repository.NewDiagramRepository(db), - repository.NewItemRepository(db), - permService, - ) -} - -// createTestDiagramForItem creates a diagram for an item and returns its ID -func createTestDiagramForItem(t *testing.T, handler *DiagramHandler, itemID int) int { - t.Helper() - body := map[string]interface{}{ - "name": "Test Diagram", - "diagram_data": `{"nodes":[],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/diagrams", itemID), body) - req.SetPathValue("itemId", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - var diagram models.ItemDiagram - rr.AssertJSONResponse(&diagram) - return diagram.ID -} - -// --- Create permission tests --- - -func TestDiagramHandler_Create_RequiresItemEditPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - body := map[string]interface{}{ - "name": "Architecture Diagram", - "diagram_data": `{"nodes":[],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/diagrams", itemID), body) - req.SetPathValue("itemId", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestDiagramHandler_Create_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - body := map[string]interface{}{ - "name": "Hacked Diagram", - "diagram_data": `{"nodes":[],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/diagrams", itemID), body) - req.SetPathValue("itemId", testutils.IntToString(itemID)) - rr := testutils.ExecuteRequest(t, handler.Create, req) - - // CheckItemPermission returns 401 when unauthenticated - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestDiagramHandler_Create_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - - body := map[string]interface{}{ - "name": "Test Diagram", - "diagram_data": `{"nodes":[],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/items/99999/diagrams", body) - req.SetPathValue("itemId", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -// --- GetByItem permission tests --- - -func TestDiagramHandler_GetByItem_RequiresItemViewPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - createTestDiagramForItem(t, handler, itemID) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/diagrams", itemID), nil) - req.SetPathValue("itemId", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetByItem, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - var diagrams []models.ItemDiagram - rr.AssertJSONResponse(&diagrams) - - if len(diagrams) == 0 { - t.Error("Expected at least one diagram") - } -} - -func TestDiagramHandler_GetByItem_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/diagrams", itemID), nil) - req.SetPathValue("itemId", testutils.IntToString(itemID)) - rr := testutils.ExecuteRequest(t, handler.GetByItem, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- Get (single diagram) permission tests --- - -func TestDiagramHandler_Get_RequiresItemViewPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/diagrams/%d", diagramID), nil) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestDiagramHandler_Get_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/diagrams/%d", diagramID), nil) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteRequest(t, handler.Get, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestDiagramHandler_Get_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/diagrams/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -// --- Update permission tests --- - -func TestDiagramHandler_Update_RequiresItemEditPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - body := map[string]interface{}{ - "name": "Updated Diagram", - "diagram_data": `{"nodes":[{"id":"1"}],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/diagrams/%d", diagramID), body) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestDiagramHandler_Update_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - body := map[string]interface{}{ - "name": "Hacked Update", - "diagram_data": `{"nodes":[],"edges":[]}`, - } - - req := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/diagrams/%d", diagramID), body) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteRequest(t, handler.Update, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- Delete permission tests --- - -func TestDiagramHandler_Delete_RequiresItemEditPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - req := testutils.CreateJSONRequest(t, "DELETE", fmt.Sprintf("/api/diagrams/%d", diagramID), nil) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestDiagramHandler_Delete_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - diagramID := createTestDiagramForItem(t, handler, itemID) - - req := testutils.CreateJSONRequest(t, "DELETE", fmt.Sprintf("/api/diagrams/%d", diagramID), nil) - req.SetPathValue("id", testutils.IntToString(diagramID)) - rr := testutils.ExecuteRequest(t, handler.Delete, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestDiagramHandler_Delete_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createDiagramHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "DELETE", "/api/diagrams/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} diff --git a/internal/handlers/item_links_test.go b/internal/handlers/item_links_test.go deleted file mode 100644 index e360db7..0000000 --- a/internal/handlers/item_links_test.go +++ /dev/null @@ -1,206 +0,0 @@ -//go:build test - -package handlers - -import ( - "fmt" - "net/http" - "testing" - - "windshift/internal/models" - "windshift/internal/testutils" -) - -// createItemLinkHandler creates an ItemLinkHandler with test services -func createItemLinkHandler(t *testing.T, tdb *testutils.TestDB) *ItemLinkHandler { - t.Helper() - permService, _, notifService := createTestServices(t, *tdb) - return NewItemLinkHandler(tdb.GetDatabase(), notifService, permService) -} - -// createSecondTestItem creates a second item in the test workspace for link tests -func createSecondTestItem(t *testing.T, tdb *testutils.TestDB, data testutils.TestDataSet) int { - t.Helper() - permService, actTracker, notifService := createTestServices(t, *tdb) - itemHandler := NewItemHandler(tdb.GetDatabase(), permService, actTracker, notifService) - - statusID := data.StatusID - priorityID := data.PriorityID - item := models.Item{ - WorkspaceID: data.WorkspaceID, - Title: "Second Item for Links", - StatusID: &statusID, - PriorityID: &priorityID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/items", item) - rr := testutils.ExecuteAuthenticatedRequest(t, itemHandler.Create, req, nil) - rr.AssertStatusCode(http.StatusCreated) - - var created models.Item - rr.AssertJSONResponse(&created) - return created.ID -} - -// createTestLinkType creates a link type through the owning repository. -func createTestLinkType(t *testing.T, tdb *testutils.TestDB) int { - t.Helper() - return newServiceSetup(t, tdb).CreateLinkType("blocks", "blocks", "is blocked by") -} - -// --- GetLinksForItem permission tests --- - -func TestItemLinkHandler_GetLinksForItem_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/links", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetLinksForItem, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestItemLinkHandler_GetLinksForItem_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/items/99999/links", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetLinksForItem, req, nil) - - // Handler now validates existence and fails closed — non-existent items - // (or items the user can't see) return 404 to avoid leaking existence. - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestItemLinkHandler_GetLinksForItem_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/items/1/links", nil) - req.SetPathValue("id", "1") - rr := testutils.ExecuteRequest(t, handler.GetLinksForItem, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- CreateLink permission tests --- - -func TestItemLinkHandler_CreateLink_RequiresSourceEditAndTargetView(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - sourceID := createTestItemForComments(t, tdb, data) - targetID := createSecondTestItem(t, tdb, data) - linkTypeID := createTestLinkType(t, tdb) - - body := map[string]interface{}{ - "source_type": "item", - "source_id": sourceID, - "target_type": "item", - "target_id": targetID, - "link_type_id": linkTypeID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/item-links", body) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateLink, req, nil) - - rr.AssertStatusCode(http.StatusCreated) -} - -func TestItemLinkHandler_CreateLink_NonExistentSource(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - targetID := createTestItemForComments(t, tdb, data) - linkTypeID := createTestLinkType(t, tdb) - - body := map[string]interface{}{ - "source_type": "item", - "source_id": 99999, - "target_type": "item", - "target_id": targetID, - "link_type_id": linkTypeID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/item-links", body) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.CreateLink, req, nil) - - // Returns 404 for non-existent source item - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestItemLinkHandler_CreateLink_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - - // Use link_type_id 999 (not 1, which is the special "Tests" type that - // requires one item and one test_case). Validation and duplicate check pass, - // then RequireAuth returns 401. - body := map[string]interface{}{ - "source_type": "item", - "source_id": 1, - "target_type": "item", - "target_id": 2, - "link_type_id": 999, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/item-links", body) - rr := testutils.ExecuteRequest(t, handler.CreateLink, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- DeleteLink permission tests --- - -func TestItemLinkHandler_DeleteLink_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createItemLinkHandler(t, tdb) - sourceID := createTestItemForComments(t, tdb, data) - targetID := createSecondTestItem(t, tdb, data) - linkTypeID := createTestLinkType(t, tdb) - - // Create a link first - body := map[string]interface{}{ - "source_type": "item", - "source_id": sourceID, - "target_type": "item", - "target_id": targetID, - "link_type_id": linkTypeID, - } - createReq := testutils.CreateJSONRequest(t, "POST", "/api/item-links", body) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.CreateLink, createReq, nil) - createRR.AssertStatusCode(http.StatusCreated) - - var createdLink models.ItemLink - createRR.AssertJSONResponse(&createdLink) - - // Try to delete without authentication using the link's ID - deleteReq := testutils.CreateJSONRequest(t, "DELETE", - fmt.Sprintf("/api/item-links/%d", createdLink.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(createdLink.ID)) - rr := testutils.ExecuteRequest(t, handler.DeleteLink, deleteReq) - - rr.AssertStatusCode(http.StatusUnauthorized) -} diff --git a/internal/handlers/iteration_patch_semantics_test.go b/internal/handlers/iteration_patch_semantics_test.go deleted file mode 100644 index 1e1449d..0000000 --- a/internal/handlers/iteration_patch_semantics_test.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build test - -package handlers - -import ( - "net/http" - "testing" - - "windshift/internal/logger" - "windshift/internal/models" - "windshift/internal/services" - "windshift/internal/testutils" -) - -func TestIterationHandlerUpdateDistinguishesOmittedEmptyAndNull(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - db := tdb.GetDatabase() - permService := createIterationTestServices(t, *tdb) - planning := services.NewPlanningService(db) - handler := NewIterationHandler(planning, permService, logger.NewAuditor(db)) - - var typeID int - if err := db.QueryRow( - "INSERT INTO iteration_types (name, color) VALUES (?, ?) RETURNING id", - "Patch type", "#123456", - ).Scan(&typeID); err != nil { - t.Fatalf("insert iteration type: %v", err) - } - iteration, err := planning.CreateIteration(services.CreateIterationParams{ - Name: "Original", - Description: "Keep me", - StartDate: "2026-07-01", - EndDate: "2026-07-14", - Status: "planned", - TypeID: &typeID, - IsGlobal: true, - }) - if err != nil { - t.Fatalf("CreateIteration: %v", err) - } - - update := func(body map[string]interface{}) models.Iteration { - t.Helper() - req := testutils.CreateJSONRequest(t, http.MethodPut, "/api/global/iterations/1", body) - req.SetPathValue("id", testutils.IntToString(iteration.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, req, nil) - rr.AssertStatusCode(http.StatusOK) - var response models.Iteration - rr.AssertJSONResponse(&response) - return response - } - - renamed := update(map[string]interface{}{"name": "Renamed"}) - if renamed.Description != "Keep me" || renamed.TypeID == nil || *renamed.TypeID != typeID { - t.Fatalf("omitted fields were not preserved: %+v", renamed) - } - clearedDescription := update(map[string]interface{}{"description": ""}) - if clearedDescription.Description != "" || clearedDescription.TypeID == nil { - t.Fatalf("empty description clear failed: %+v", clearedDescription) - } - clearedType := update(map[string]interface{}{"type_id": nil}) - if clearedType.TypeID != nil || clearedType.Name != "Renamed" { - t.Fatalf("null type clear failed: %+v", clearedType) - } -} diff --git a/internal/handlers/iterations_test.go b/internal/handlers/iterations_test.go deleted file mode 100644 index 50d4df6..0000000 --- a/internal/handlers/iterations_test.go +++ /dev/null @@ -1,620 +0,0 @@ -//go:build test - -package handlers - -import ( - "net/http" - "strings" - "testing" - "time" - - "windshift/internal/logger" - "windshift/internal/models" - "windshift/internal/services" - "windshift/internal/testutils" -) - -// createIterationTestServices creates the permission service needed for iteration handler tests -// and grants the test user global iteration management permissions. -// It also ensures the test user exists by seeding basic test data if not already seeded. -func createIterationTestServices(t *testing.T, db testutils.TestDB) *services.PermissionService { - t.Helper() - - // Check if test user already exists, if not seed the test data - var userCount int - err := db.GetDatabase().QueryRow("SELECT COUNT(*) FROM users WHERE id = 1").Scan(&userCount) - if err != nil || userCount == 0 { - // Seed basic test data to ensure user ID 1 exists - db.SeedTestData(t) - } - - // Create permission service with test-friendly config - permConfig := services.DefaultPermissionCacheConfig() - permConfig.WarmupOnStartup = false // Don't warm up during tests - permConfig.TTL = 1 * time.Minute // Short TTL for tests - - permService, err := services.NewPermissionService(db.GetDatabase(), permConfig) - if err != nil { - t.Fatalf("Failed to create permission service: %v", err) - } - - // Register cleanup - t.Cleanup(func() { - permService.Close() - }) - - // Grant the test user (ID 1) global iteration.manage permission through - // the permission API - newServiceSetup(t, &db).GrantGlobal(1, models.PermissionIterationManage) - - // Invalidate the permission cache for user 1 - permService.InvalidateUserCache(1) - - return permService -} - -func TestIterationHandler_Create_Success_Global(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - iteration := models.Iteration{ - Name: "Sprint 1", - Description: "First sprint iteration", - StartDate: "2025-01-01", - EndDate: "2025-01-14", - Status: "planned", - IsGlobal: true, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated). - AssertContentType("application/json") - - var response models.Iteration - rr.AssertJSONResponse(&response) - - if response.ID == 0 { - t.Error("Expected created iteration to have an ID") - } - if response.Name != iteration.Name { - t.Errorf("Expected name %s, got %s", iteration.Name, response.Name) - } - if response.Status != iteration.Status { - t.Errorf("Expected status %s, got %s", iteration.Status, response.Status) - } - if !response.IsGlobal { - t.Error("Expected iteration to be global") - } - if response.WorkspaceID != nil { - t.Error("Expected global iteration to have no workspace ID") - } -} - -func TestIterationHandler_Create_Success_Local(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - workspaceID := data.WorkspaceID - iteration := models.Iteration{ - Name: "Local Sprint", - StartDate: "2025-02-01", - EndDate: "2025-02-14", - Status: "active", - IsGlobal: false, - WorkspaceID: &workspaceID, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated). - AssertContentType("application/json") - - var response models.Iteration - rr.AssertJSONResponse(&response) - - if response.IsGlobal { - t.Error("Expected iteration to be local") - } - if response.WorkspaceID == nil || *response.WorkspaceID != workspaceID { - t.Errorf("Expected workspace ID %d, got %v", workspaceID, response.WorkspaceID) - } -} - -func TestIterationHandler_Create_ValidationErrors(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - workspaceID := data.WorkspaceID - - tests := []struct { - name string - iteration models.Iteration - expectedErr string - }{ - { - name: "Missing name", - iteration: models.Iteration{StartDate: "2025-01-01", EndDate: "2025-01-14", Status: "planned", IsGlobal: true}, - expectedErr: "Iteration name is required", - }, - { - name: "Empty name", - iteration: models.Iteration{Name: " ", StartDate: "2025-01-01", EndDate: "2025-01-14", Status: "planned", IsGlobal: true}, - expectedErr: "Iteration name is required", - }, - { - name: "Missing start date", - iteration: models.Iteration{Name: "Test", EndDate: "2025-01-14", Status: "planned", IsGlobal: true}, - expectedErr: "Start date is required", - }, - { - name: "Missing end date", - iteration: models.Iteration{Name: "Test", StartDate: "2025-01-01", Status: "planned", IsGlobal: true}, - expectedErr: "End date is required", - }, - { - name: "Global with workspace ID", - iteration: models.Iteration{Name: "Test", StartDate: "2025-01-01", EndDate: "2025-01-14", Status: "planned", IsGlobal: true, WorkspaceID: &workspaceID}, - expectedErr: "Global iterations cannot have a workspace_id", - }, - { - name: "Local without workspace ID", - iteration: models.Iteration{Name: "Test", StartDate: "2025-01-01", EndDate: "2025-01-14", Status: "planned", IsGlobal: false}, - expectedErr: "Local iterations must have a workspace_id", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", tt.iteration) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusBadRequest) - if !strings.Contains(rr.Body.String(), tt.expectedErr) { - t.Errorf("Expected body to contain %q, got %q", tt.expectedErr, rr.Body.String()) - } - }) - } -} - -func TestIterationHandler_Create_DefaultStatus(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - iteration := models.Iteration{ - Name: "Default Status Test", - StartDate: "2025-01-01", - EndDate: "2025-01-14", - Status: "invalid-status", - IsGlobal: true, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated) - - var response models.Iteration - rr.AssertJSONResponse(&response) - - if response.Status != "planned" { - t.Errorf("Expected default status 'planned', got %s", response.Status) - } -} - -func TestIterationHandler_Create_InvalidWorkspace(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - invalidWorkspace := 99999 - iteration := models.Iteration{ - Name: "Test", - StartDate: "2025-01-01", - EndDate: "2025-01-14", - Status: "planned", - IsGlobal: false, - WorkspaceID: &invalidWorkspace, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - // With permission checks, we now return 403 Forbidden for workspaces the user doesn't have access to - // This is more secure as it doesn't reveal whether the workspace exists or not - rr.AssertStatusCode(http.StatusForbidden) -} - -func TestIterationHandler_GetAll_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create global iterations - iterations := []models.Iteration{ - {Name: "Sprint 1", StartDate: "2025-01-01", EndDate: "2025-01-14", Status: "completed", IsGlobal: true}, - {Name: "Sprint 2", StartDate: "2025-01-15", EndDate: "2025-01-28", Status: "active", IsGlobal: true}, - } - - for _, iter := range iterations { - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iter) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - } - - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response []models.Iteration - rr.AssertJSONResponse(&response) - - if len(response) < 2 { - t.Errorf("Expected at least 2 iterations, got %d", len(response)) - } -} - -func TestIterationHandler_GetAll_FilterByWorkspace(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - workspaceID := data.WorkspaceID - - // Create a local iteration for the workspace - localIteration := models.Iteration{ - Name: "Local Iteration", - StartDate: "2025-03-01", - EndDate: "2025-03-14", - Status: "planned", - IsGlobal: false, - WorkspaceID: &workspaceID, - } - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", localIteration) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - // Create a global iteration - globalIteration := models.Iteration{ - Name: "Global Iteration", - StartDate: "2025-04-01", - EndDate: "2025-04-14", - Status: "planned", - IsGlobal: true, - } - createReq2 := testutils.CreateJSONRequest(t, "POST", "/api/iterations", globalIteration) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq2, nil) - - // Get iterations for workspace (should include both local and global) - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations?workspace_id="+testutils.IntToString(workspaceID), nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - var response []models.Iteration - rr.AssertJSONResponse(&response) - - foundLocal := false - foundGlobal := false - for _, iter := range response { - if iter.Name == "Local Iteration" { - foundLocal = true - } - if iter.Name == "Global Iteration" { - foundGlobal = true - } - } - - if !foundLocal { - t.Error("Expected to find local iteration in workspace results") - } - if !foundGlobal { - t.Error("Expected to find global iteration in workspace results") - } -} - -func TestIterationHandler_GetAll_FilterByStatus(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create iterations with different statuses - iterations := []models.Iteration{ - {Name: "Planned Iteration", StartDate: "2025-05-01", EndDate: "2025-05-14", Status: "planned", IsGlobal: true}, - {Name: "Active Iteration", StartDate: "2025-06-01", EndDate: "2025-06-14", Status: "active", IsGlobal: true}, - {Name: "Completed Iteration", StartDate: "2025-07-01", EndDate: "2025-07-14", Status: "completed", IsGlobal: true}, - } - - for _, iter := range iterations { - req := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iter) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - } - - // Get only active iterations - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations?status=active", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - var response []models.Iteration - rr.AssertJSONResponse(&response) - - for _, iter := range response { - if iter.Status != "active" { - t.Errorf("Expected only active iterations, got status %s", iter.Status) - } - } -} - -func TestIterationHandler_Get_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - iteration := models.Iteration{ - Name: "Test Get Iteration", - Description: "Test description", - StartDate: "2025-08-01", - EndDate: "2025-08-14", - Status: "active", - IsGlobal: true, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Iteration - createRR.AssertJSONResponse(&created) - - // Get the iteration - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations/"+testutils.IntToString(created.ID), nil) - req.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.Iteration - rr.AssertJSONResponse(&response) - - if response.ID != created.ID { - t.Errorf("Expected ID %d, got %d", created.ID, response.ID) - } - if response.Name != iteration.Name { - t.Errorf("Expected name %s, got %s", iteration.Name, response.Name) - } - if response.Description != iteration.Description { - t.Errorf("Expected description %s, got %s", iteration.Description, response.Description) - } -} - -func TestIterationHandler_Get_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) - if !strings.Contains(rr.Body.String(), "iteration not found") { - t.Errorf("Expected 'iteration not found', got %s", rr.Body.String()) - } -} - -func TestIterationHandler_Update_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create an iteration - iteration := models.Iteration{ - Name: "Original Name", - Description: "Original description", - StartDate: "2025-09-01", - EndDate: "2025-09-14", - Status: "planned", - IsGlobal: true, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Iteration - createRR.AssertJSONResponse(&created) - - // Update the iteration - updateData := models.Iteration{ - Name: "Updated Name", - Description: "Updated description", - StartDate: "2025-09-05", - EndDate: "2025-09-20", - Status: "active", - IsGlobal: true, - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/iterations/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.Iteration - rr.AssertJSONResponse(&response) - - if response.Name != "Updated Name" { - t.Errorf("Expected name 'Updated Name', got %s", response.Name) - } - if response.Description != "Updated description" { - t.Errorf("Expected description 'Updated description', got %s", response.Description) - } - if response.Status != "active" { - t.Errorf("Expected status 'active', got %s", response.Status) - } -} - -func TestIterationHandler_Update_InvalidStatus(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create an iteration - iteration := models.Iteration{ - Name: "Test", - StartDate: "2025-10-01", - EndDate: "2025-10-14", - Status: "planned", - IsGlobal: true, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Iteration - createRR.AssertJSONResponse(&created) - - // Try to update with invalid status - updateData := models.Iteration{ - Name: "Test", - StartDate: "2025-10-01", - EndDate: "2025-10-14", - Status: "invalid-status", - IsGlobal: true, - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/iterations/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusBadRequest) - if !strings.Contains(rr.Body.String(), "Invalid status") { - t.Errorf("Expected 'Invalid status' error, got %s", rr.Body.String()) - } -} - -func TestIterationHandler_Delete_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create an iteration - iteration := models.Iteration{ - Name: "To Delete", - StartDate: "2025-11-01", - EndDate: "2025-11-14", - Status: "planned", - IsGlobal: true, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Iteration - createRR.AssertJSONResponse(&created) - - // Delete the iteration - deleteReq := testutils.CreateJSONRequest(t, "DELETE", "/api/iterations/"+testutils.IntToString(created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, deleteReq, nil) - - rr.AssertStatusCode(http.StatusNoContent) - - // Verify iteration is deleted - getReq := testutils.CreateJSONRequest(t, "GET", "/api/iterations/"+testutils.IntToString(created.ID), nil) - getReq.SetPathValue("id", testutils.IntToString(created.ID)) - getRR := testutils.ExecuteAuthenticatedRequest(t, handler.Get, getReq, nil) - - getRR.AssertStatusCode(http.StatusNotFound) -} - -func TestIterationHandler_GetProgress_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - // Create an iteration - iteration := models.Iteration{ - Name: "Progress Test", - StartDate: "2025-12-01", - EndDate: "2025-12-14", - Status: "active", - IsGlobal: true, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/iterations", iteration) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Iteration - createRR.AssertJSONResponse(&created) - - // Get progress report - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations/"+testutils.IntToString(created.ID)+"/progress", nil) - req.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetProgress, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response services.IterationProgressReport - rr.AssertJSONResponse(&response) - - if response.IterationID != created.ID { - t.Errorf("Expected iteration ID %d, got %d", created.ID, response.IterationID) - } - if response.IterationName != iteration.Name { - t.Errorf("Expected iteration name %s, got %s", iteration.Name, response.IterationName) - } -} - -func TestIterationHandler_GetProgress_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - permService := createIterationTestServices(t, *tdb) - handler := NewIterationHandler(services.NewPlanningService(tdb.GetDatabase()), permService, logger.NewAuditor(tdb.GetDatabase())) - - req := testutils.CreateJSONRequest(t, "GET", "/api/iterations/99999/progress", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetProgress, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} diff --git a/internal/handlers/labels_test.go b/internal/handlers/labels_test.go deleted file mode 100644 index ee1b176..0000000 --- a/internal/handlers/labels_test.go +++ /dev/null @@ -1,314 +0,0 @@ -//go:build test - -package handlers - -import ( - "fmt" - "net/http" - "testing" - - "windshift/internal/logger" - "windshift/internal/models" - "windshift/internal/repository" - "windshift/internal/testutils" -) - -// createLabelHandler creates a LabelHandler with test services -func createLabelHandler(t *testing.T, tdb *testutils.TestDB) *LabelHandler { - t.Helper() - permService, _, _ := createTestServices(t, *tdb) - db := tdb.GetDatabase() - return NewLabelHandler( - repository.NewLabelRepository(db), - repository.NewItemRepository(db), - permService, - logger.NewAuditor(db), - ) -} - -// createTestLabel creates a global label using a workspace as authorization -// context and returns it. -func createTestLabel(t *testing.T, tdb *testutils.TestDB, workspaceID int, name string) models.Label { - t.Helper() - return newServiceSetup(t, tdb).CreateLabel(workspaceID, name) -} - -// --- GetAll permission tests --- - -func TestLabelHandler_GetAll_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/labels?workspace_id=1", nil) - rr := testutils.ExecuteRequest(t, handler.GetAll, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestLabelHandler_GetAll_IgnoresLegacyWorkspaceFilter(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/labels?workspace_id=99999", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestLabelHandler_GetAll_WithPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - // Create a label in the workspace - createTestLabel(t, tdb, data.WorkspaceID, "Test Label") - - req := testutils.CreateJSONRequest(t, "GET", "/api/labels", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var labels []models.Label - rr.AssertJSONResponse(&labels) - - if len(labels) == 0 { - t.Error("Expected at least one label") - } -} - -func TestLabelHandler_GetAll_ReturnsGlobalCatalog(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - localLabel := createTestLabel(t, tdb, data.WorkspaceID, "Local") - foreignWorkspaceID := newServiceSetup(t, tdb).CreateWorkspace("Foreign", "FOREIGN") - foreignLabel := createTestLabel(t, tdb, foreignWorkspaceID, "Foreign") - - req := testutils.CreateJSONRequest(t, "GET", "/api/labels", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK) - var labels []models.Label - rr.AssertJSONResponse(&labels) - got := map[int]bool{} - for _, label := range labels { - got[label.ID] = true - } - if !got[localLabel.ID] || !got[foreignLabel.ID] { - t.Fatalf("global labels = %#v, want label IDs %d and %d", labels, localLabel.ID, foreignLabel.ID) - } -} - -func TestLabelHandler_Create_RejectsDuplicateFromAnotherWorkspaceContext(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - createTestLabel(t, tdb, data.WorkspaceID, "Shared") - foreignWorkspaceID := newServiceSetup(t, tdb).CreateWorkspace("Foreign", "FOREIGN") - req := testutils.CreateJSONRequest(t, "POST", "/api/labels", map[string]any{ - "name": "shared", - "workspace_id": foreignWorkspaceID, - }) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusConflict) -} - -// --- Get permission tests --- - -func TestLabelHandler_Get_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - label := createTestLabel(t, tdb, data.WorkspaceID, "Secret Label") - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/labels/%d", label.ID), nil) - req.SetPathValue("id", testutils.IntToString(label.ID)) - rr := testutils.ExecuteRequest(t, handler.Get, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- GetItemLabels permission tests --- - -func TestLabelHandler_GetItemLabels_RequiresItemViewPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - // User with permission should succeed - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/labels", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetItemLabels, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestLabelHandler_GetItemLabels_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/items/99999/labels", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetItemLabels, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestLabelHandler_GetItemLabels_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - req := testutils.CreateJSONRequest(t, "GET", fmt.Sprintf("/api/items/%d/labels", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteRequest(t, handler.GetItemLabels, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- SetItemLabels permission tests --- - -func TestLabelHandler_SetItemLabels_RequiresItemEditPermission(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - label := createTestLabel(t, tdb, data.WorkspaceID, "Apply Label") - - body := map[string]interface{}{ - "label_ids": []int{label.ID}, - } - - req := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/items/%d/labels", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.SetItemLabels, req, nil) - - rr.AssertStatusCode(http.StatusOK) -} - -func TestLabelHandler_SetItemLabels_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - body := map[string]interface{}{ - "label_ids": []int{1}, - } - - req := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/items/%d/labels", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteRequest(t, handler.SetItemLabels, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestLabelHandler_SetItemLabels_AcceptsGlobalLabelCreatedFromAnotherWorkspace(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - foreignWorkspaceID := newServiceSetup(t, tdb).CreateWorkspace("Foreign", "FOREIGN") - globalLabel := createTestLabel(t, tdb, foreignWorkspaceID, "Global") - req := testutils.CreateJSONRequest(t, "PUT", fmt.Sprintf("/api/items/%d/labels", itemID), map[string]any{ - "label_ids": []int{globalLabel.ID}, - }) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.SetItemLabels, req, nil) - - rr.AssertStatusCode(http.StatusOK) - - labels, err := repository.NewLabelRepository(tdb.GetDatabase()).ListForItem(itemID) - if err != nil { - t.Fatalf("list labels after replacement: %v", err) - } - if len(labels) != 1 || labels[0].ID != globalLabel.ID { - t.Fatalf("labels after replacement = %#v, want global label %d", labels, globalLabel.ID) - } -} - -// --- AddItemLabel permission tests --- - -func TestLabelHandler_AddItemLabel_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - body := map[string]interface{}{ - "label_id": 1, - } - - req := testutils.CreateJSONRequest(t, "POST", fmt.Sprintf("/api/items/%d/labels", itemID), body) - req.SetPathValue("id", testutils.IntToString(itemID)) - rr := testutils.ExecuteRequest(t, handler.AddItemLabel, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -// --- RemoveItemLabel permission tests --- - -func TestLabelHandler_RemoveItemLabel_Unauthenticated(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - itemID := createTestItemForComments(t, tdb, data) - - req := testutils.CreateJSONRequest(t, "DELETE", fmt.Sprintf("/api/items/%d/labels/1", itemID), nil) - req.SetPathValue("id", testutils.IntToString(itemID)) - req.SetPathValue("labelId", "1") - rr := testutils.ExecuteRequest(t, handler.RemoveItemLabel, req) - - rr.AssertStatusCode(http.StatusUnauthorized) -} - -func TestLabelHandler_RemoveItemLabel_NonExistentItem(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - tdb.SeedTestData(t) - handler := createLabelHandler(t, tdb) - - req := testutils.CreateJSONRequest(t, "DELETE", "/api/items/99999/labels/1", nil) - req.SetPathValue("id", "99999") - req.SetPathValue("labelId", "1") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.RemoveItemLabel, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} diff --git a/internal/handlers/link_types_test.go b/internal/handlers/link_types_test.go deleted file mode 100644 index 0a3a15c..0000000 --- a/internal/handlers/link_types_test.go +++ /dev/null @@ -1,445 +0,0 @@ -//go:build test - -package handlers - -import ( - "net/http" - "strings" - "testing" - - "windshift/internal/logger" - "windshift/internal/models" - "windshift/internal/repository" - "windshift/internal/testutils" -) - -func newTestLinkTypeHandler(tdb *testutils.TestDB) *LinkTypeHandler { - db := tdb.GetDatabase() - return NewLinkTypeHandler(repository.NewLinkTypeRepository(db), logger.NewAuditor(db)) -} - -func TestLinkTypeHandler_Create_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - linkType := models.LinkType{ - Name: "Blocks", - Description: "This item blocks another", - ForwardLabel: "blocks", - ReverseLabel: "is blocked by", - Color: "#ff0000", - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated). - AssertContentType("application/json") - - var response models.LinkType - rr.AssertJSONResponse(&response) - - if response.ID == 0 { - t.Error("Expected created link type to have an ID") - } - if response.Name != linkType.Name { - t.Errorf("Expected name %s, got %s", linkType.Name, response.Name) - } - if response.ForwardLabel != linkType.ForwardLabel { - t.Errorf("Expected forward_label %s, got %s", linkType.ForwardLabel, response.ForwardLabel) - } - if response.ReverseLabel != linkType.ReverseLabel { - t.Errorf("Expected reverse_label %s, got %s", linkType.ReverseLabel, response.ReverseLabel) - } - if !response.Active { - t.Error("Expected link type to be active by default") - } - if response.IsSystem { - t.Error("Expected user-created link type to not be a system type") - } -} - -func TestLinkTypeHandler_Create_DefaultColor(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - linkType := models.LinkType{ - Name: "Custom Relates To", - ForwardLabel: "relates to", - ReverseLabel: "relates to", - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated) - - var response models.LinkType - rr.AssertJSONResponse(&response) - - if response.Color != "#6b7280" { - t.Errorf("Expected default color #6b7280, got %s", response.Color) - } -} - -func TestLinkTypeHandler_Create_ValidationErrors(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - tests := []struct { - name string - linkType models.LinkType - expectedErr string - }{ - { - name: "Missing name", - linkType: models.LinkType{ForwardLabel: "blocks", ReverseLabel: "is blocked by"}, - expectedErr: "Name, forward_label, and reverse_label are required", - }, - { - name: "Missing forward_label", - linkType: models.LinkType{Name: "Blocks", ReverseLabel: "is blocked by"}, - expectedErr: "Name, forward_label, and reverse_label are required", - }, - { - name: "Missing reverse_label", - linkType: models.LinkType{Name: "Blocks", ForwardLabel: "blocks"}, - expectedErr: "Name, forward_label, and reverse_label are required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testutils.CreateJSONRequest(t, "POST", "/api/link-types", tt.linkType) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusBadRequest) - if !strings.Contains(rr.Body.String(), tt.expectedErr) { - t.Errorf("Expected body to contain %q, got %q", tt.expectedErr, rr.Body.String()) - } - }) - } -} - -func TestLinkTypeHandler_GetAll_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create test link types - linkTypes := []models.LinkType{ - {Name: "Blocks", ForwardLabel: "blocks", ReverseLabel: "is blocked by"}, - {Name: "Duplicates", ForwardLabel: "duplicates", ReverseLabel: "is duplicated by"}, - } - - for _, lt := range linkTypes { - req := testutils.CreateJSONRequest(t, "POST", "/api/link-types", lt) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - } - - req := testutils.CreateJSONRequest(t, "GET", "/api/link-types", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response []models.LinkType - rr.AssertJSONResponse(&response) - - // Should include at least our 2 created types (may also have system types) - if len(response) < 2 { - t.Errorf("Expected at least 2 link types, got %d", len(response)) - } -} - -func TestLinkTypeHandler_GetAll_IncludeInactive(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create an active link type - linkType := models.LinkType{ - Name: "Test Type", - ForwardLabel: "forward", - ReverseLabel: "reverse", - } - req := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - var created models.LinkType - createRR.AssertJSONResponse(&created) - - // Deactivate it via update - created.Active = false - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/link-types/"+testutils.IntToString(created.ID), created) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - // Get all without include_inactive - should not include the inactive one - getReq := testutils.CreateJSONRequest(t, "GET", "/api/link-types", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, getReq, nil) - - var response []models.LinkType - rr.AssertJSONResponse(&response) - - for _, lt := range response { - if lt.ID == created.ID { - t.Error("Did not expect inactive link type in results") - } - } - - // Get all with include_inactive - should include it - getReqWithInactive := testutils.CreateJSONRequest(t, "GET", "/api/link-types?include_inactive=true", nil) - rrWithInactive := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, getReqWithInactive, nil) - - var responseWithInactive []models.LinkType - rrWithInactive.AssertJSONResponse(&responseWithInactive) - - found := false - for _, lt := range responseWithInactive { - if lt.ID == created.ID { - found = true - break - } - } - if !found { - t.Error("Expected inactive link type in results when include_inactive=true") - } -} - -func TestLinkTypeHandler_Get_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create test link type - linkType := models.LinkType{ - Name: "Test Get", - Description: "Test description", - ForwardLabel: "forward", - ReverseLabel: "reverse", - Color: "#123456", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.LinkType - createRR.AssertJSONResponse(&created) - - // Get the link type - req := testutils.CreateJSONRequest(t, "GET", "/api/link-types/"+testutils.IntToString(created.ID), nil) - req.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.LinkType - rr.AssertJSONResponse(&response) - - if response.ID != created.ID { - t.Errorf("Expected ID %d, got %d", created.ID, response.ID) - } - if response.Name != linkType.Name { - t.Errorf("Expected name %s, got %s", linkType.Name, response.Name) - } - if response.Description != linkType.Description { - t.Errorf("Expected description %s, got %s", linkType.Description, response.Description) - } -} - -func TestLinkTypeHandler_Get_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - req := testutils.CreateJSONRequest(t, "GET", "/api/link-types/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestLinkTypeHandler_Update_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create test link type - linkType := models.LinkType{ - Name: "Original Name", - Description: "Original description", - ForwardLabel: "original forward", - ReverseLabel: "original reverse", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.LinkType - createRR.AssertJSONResponse(&created) - - // Update the link type - updateData := models.LinkType{ - Name: "Updated Name", - Description: "Updated description", - ForwardLabel: "updated forward", - ReverseLabel: "updated reverse", - Color: "#abcdef", - Active: true, - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/link-types/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.LinkType - rr.AssertJSONResponse(&response) - - if response.Name != "Updated Name" { - t.Errorf("Expected name 'Updated Name', got %s", response.Name) - } - if response.Description != "Updated description" { - t.Errorf("Expected description 'Updated description', got %s", response.Description) - } - if response.ForwardLabel != "updated forward" { - t.Errorf("Expected forward_label 'updated forward', got %s", response.ForwardLabel) - } - if response.Color != "#abcdef" { - t.Errorf("Expected color '#abcdef', got %s", response.Color) - } -} - -func TestLinkTypeHandler_Update_ValidationError(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create test link type - linkType := models.LinkType{ - Name: "Test Type", - ForwardLabel: "forward", - ReverseLabel: "reverse", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.LinkType - createRR.AssertJSONResponse(&created) - - // Try to update with missing required field - updateData := models.LinkType{ - Name: "", - ForwardLabel: "forward", - ReverseLabel: "reverse", - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/link-types/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusBadRequest) -} - -func TestLinkTypeHandler_Delete_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Create test link type - linkType := models.LinkType{ - Name: "To Delete", - ForwardLabel: "forward", - ReverseLabel: "reverse", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/link-types", linkType) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.LinkType - createRR.AssertJSONResponse(&created) - - // Delete the link type - deleteReq := testutils.CreateJSONRequest(t, "DELETE", "/api/link-types/"+testutils.IntToString(created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, deleteReq, nil) - - rr.AssertStatusCode(http.StatusNoContent) - - // Verify link type is deleted - getReq := testutils.CreateJSONRequest(t, "GET", "/api/link-types/"+testutils.IntToString(created.ID), nil) - getReq.SetPathValue("id", testutils.IntToString(created.ID)) - getRR := testutils.ExecuteAuthenticatedRequest(t, handler.Get, getReq, nil) - - getRR.AssertStatusCode(http.StatusNotFound) -} - -func TestLinkTypeHandler_Delete_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - req := testutils.CreateJSONRequest(t, "DELETE", "/api/link-types/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestLinkTypeHandler_Delete_SystemType(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := newTestLinkTypeHandler(tdb) - - // Check if there are system link types in the database - rows, err := tdb.Query("SELECT id FROM link_types WHERE is_system = true LIMIT 1") - if err != nil { - t.Fatalf("Failed to query system link types: %v", err) - } - defer func() { _ = rows.Close() }() - - var systemID int - if !rows.Next() { - // Create a system link type manually for testing - _, err := tdb.Exec(` - INSERT INTO link_types (name, forward_label, reverse_label, is_system, active, created_at, updated_at) - VALUES ('System Type', 'sys forward', 'sys reverse', true, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - `) - if err != nil { - t.Fatalf("Failed to create system link type: %v", err) - } - err = tdb.QueryRow("SELECT id FROM link_types WHERE name = 'System Type'").Scan(&systemID) - if err != nil { - t.Fatalf("Failed to get system link type ID: %v", err) - } - } else { - rows.Scan(&systemID) - } - - // Try to delete system link type - req := testutils.CreateJSONRequest(t, "DELETE", "/api/link-types/"+testutils.IntToString(systemID), nil) - req.SetPathValue("id", testutils.IntToString(systemID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, req, nil) - - rr.AssertStatusCode(http.StatusForbidden) -} diff --git a/internal/handlers/page_diagrams_test.go b/internal/handlers/page_diagrams_test.go deleted file mode 100644 index 1bd575c..0000000 --- a/internal/handlers/page_diagrams_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - "windshift/internal/database" - "windshift/internal/services" -) - -func wirePageDiagramHandler(t *testing.T) (*PageHandler, database.Database, int) { - t.Helper() - h, db, permissions := newPageHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - h.SetPageDiagramService(services.NewPageDiagramService( - db, - t.TempDir(), - h.PageApplicationService(), - h.pageAuth, - permissions, - )) - return h, db, userID -} - -func TestPageHandler_DiagramCreateListGetAndUpdate(t *testing.T) { - h, _, userID := wirePageDiagramHandler(t) - page, err := h.service.Create(userID, services.CreatePageInput{ - WorkspaceID: 1, - Title: "Architecture", - Content: "# Architecture", - }) - if err != nil { - t.Fatalf("create page: %v", err) - } - - createBody := map[string]interface{}{ - "name": "Flow", - "mermaid": "graph TD; A-->B", - "placement": "end", - "expected_content_hash": page.ContentHash, - } - createReq := authedRequest(http.MethodPost, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/diagrams", userID, createBody) - setPath(createReq, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - createRR := httptest.NewRecorder() - h.CreateDiagram(createRR, createReq) - if createRR.Code != http.StatusCreated { - t.Fatalf("create status: want 201, got %d body=%s", createRR.Code, createRR.Body.String()) - } - var created services.PageDiagram - decodeJSONBody(t, createRR, &created) - if created.AttachmentID == 0 || created.Kind != services.DiagramKindMermaid || created.ContentHash == "" { - t.Fatalf("created diagram: %+v", created) - } - - listReq := authedRequest(http.MethodGet, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/diagrams", userID, nil) - setPath(listReq, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - listRR := httptest.NewRecorder() - h.ListDiagrams(listRR, listReq) - if listRR.Code != http.StatusOK { - t.Fatalf("list status: want 200, got %d body=%s", listRR.Code, listRR.Body.String()) - } - var listed struct { - Items []services.PageDiagram `json:"items"` - } - decodeJSONBody(t, listRR, &listed) - if len(listed.Items) != 1 || listed.Items[0].AttachmentID != created.AttachmentID { - t.Fatalf("listed diagrams: %+v", listed.Items) - } - - getReq := authedRequest(http.MethodGet, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/diagrams/"+strconv.Itoa(created.AttachmentID), userID, nil) - setPath(getReq, map[string]string{ - "workspaceId": "1", - "pageId": strconv.Itoa(page.ID), - "attachmentId": strconv.Itoa(created.AttachmentID), - }) - getRR := httptest.NewRecorder() - h.GetDiagram(getRR, getReq) - if getRR.Code != http.StatusOK { - t.Fatalf("get status: want 200, got %d body=%s", getRR.Code, getRR.Body.String()) - } - var fetched services.PageDiagram - decodeJSONBody(t, getRR, &fetched) - var seed map[string]string - if err := json.Unmarshal(fetched.Payload, &seed); err != nil { - t.Fatalf("decode seed: %v", err) - } - if seed["source"] != "graph TD; A-->B" { - t.Fatalf("fetched seed: %#v", seed) - } - - scene := map[string]interface{}{ - "elements": []map[string]interface{}{{"id": "one", "type": "rectangle"}}, - "appState": map[string]interface{}{}, - "files": map[string]interface{}{}, - } - updateBody := map[string]interface{}{ - "name": "Renamed flow", - "excalidraw": scene, - "expected_content_hash": created.ContentHash, - } - updateReq := authedRequest(http.MethodPut, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/diagrams/"+strconv.Itoa(created.AttachmentID), userID, updateBody) - setPath(updateReq, map[string]string{ - "workspaceId": "1", - "pageId": strconv.Itoa(page.ID), - "attachmentId": strconv.Itoa(created.AttachmentID), - }) - updateRR := httptest.NewRecorder() - h.UpdateDiagram(updateRR, updateReq) - if updateRR.Code != http.StatusOK { - t.Fatalf("update status: want 200, got %d body=%s", updateRR.Code, updateRR.Body.String()) - } - var updated services.PageDiagram - decodeJSONBody(t, updateRR, &updated) - if updated.AttachmentID == created.AttachmentID || updated.Name != "Renamed flow" || - updated.Kind != services.DiagramKindExcalidraw { - t.Fatalf("updated diagram: %+v", updated) - } - current, err := h.service.GetByID(page.ID) - if err != nil { - t.Fatalf("get updated page: %v", err) - } - if strings.Contains(current.Content, `"attachmentId":`+strconv.Itoa(created.AttachmentID)) || - !strings.Contains(current.Content, `"attachmentId":`+strconv.Itoa(updated.AttachmentID)) || - !strings.Contains(current.Content, `"name":"Renamed flow"`) { - t.Fatalf("updated page fence: %q", current.Content) - } -} - -func TestPageHandler_DiagramMutationMasksCrossWorkspaceAndViewerDenials(t *testing.T) { - h, db, editorID := wirePageDiagramHandler(t) - page, err := h.service.Create(editorID, services.CreatePageInput{ - WorkspaceID: 1, - Title: "Protected", - }) - if err != nil { - t.Fatalf("create page: %v", err) - } - - crossReq := authedRequest(http.MethodPost, "/workspaces/2/pages/"+strconv.Itoa(page.ID)+"/diagrams", editorID, map[string]interface{}{ - "name": "Nope", "mermaid": "graph TD; A-->B", "placement": "end", - }) - setPath(crossReq, map[string]string{"workspaceId": "2", "pageId": strconv.Itoa(page.ID)}) - crossRR := httptest.NewRecorder() - h.CreateDiagram(crossRR, crossReq) - if crossRR.Code != http.StatusNotFound { - t.Fatalf("cross-workspace status: want 404, got %d body=%s", crossRR.Code, crossRR.Body.String()) - } - - const viewerID = 2 - seedNegativeTestUser(t, db, viewerID) - assignWorkspaceRole(t, db, 1, viewerID, "Viewer") - viewerReq := authedRequest(http.MethodPost, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/diagrams", viewerID, map[string]interface{}{ - "name": "Nope", "mermaid": "graph TD; A-->B", "placement": "end", - }) - setPath(viewerReq, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - viewerRR := httptest.NewRecorder() - h.CreateDiagram(viewerRR, viewerReq) - if viewerRR.Code != http.StatusNotFound { - t.Fatalf("viewer status: want 404, got %d body=%s", viewerRR.Code, viewerRR.Body.String()) - } -} diff --git a/internal/handlers/page_labels_test.go b/internal/handlers/page_labels_test.go deleted file mode 100644 index fede20a..0000000 --- a/internal/handlers/page_labels_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package handlers - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "windshift/internal/database" - "windshift/internal/logger" - "windshift/internal/models" - "windshift/internal/repository" - "windshift/internal/services" -) - -// newPageLabelHandler wires a fully-initialized SQLite DB, the real -// PermissionService, and the page services into a PageLabelHandler suitable -// for integration tests. Also returns a PageHandler so tests can seed pages. -func newPageLabelHandler(t *testing.T) (*PageLabelHandler, *PageHandler, database.Database) { - t.Helper() - db := newNegativeTestDB(t) - perm := newNegativeTestPermissionService(t, db) - auth := services.NewPagePermissionService(db, perm) - pageRepo := repository.NewPageLabelRepository(db) - pageSvc := services.NewPageService(db) - pageSvc.SetPageLabelRepository(pageRepo) - pageHandler := NewPageHandler(pageSvc, auth, perm, logger.NewAuditor(db)) - labelHandler := NewPageLabelHandler(pageRepo, auth, logger.NewAuditor(db)) - return labelHandler, pageHandler, db -} - -func TestPageLabels_Create_RejectsViewer(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Viewer") - - body := map[string]interface{}{"name": "design", "color": "#3B82F6"} - req := authedRequest(http.MethodPost, "/workspaces/1/page-labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1"}) - rr := httptest.NewRecorder() - h.Create(rr, req) - - if rr.Code != http.StatusNotFound { - t.Errorf("viewer create: want 404 (no leak), got %d body=%s", rr.Code, rr.Body.String()) - } - - labels, err := h.repo.ListByWorkspace(1) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(labels) != 0 { - t.Errorf("viewer should not have created any labels, got %d", len(labels)) - } -} - -func TestPageLabels_Create_EditorSucceeds(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - body := map[string]interface{}{"name": "design"} - req := authedRequest(http.MethodPost, "/workspaces/1/page-labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1"}) - rr := httptest.NewRecorder() - h.Create(rr, req) - - if rr.Code != http.StatusCreated { - t.Fatalf("editor create: want 201, got %d body=%s", rr.Code, rr.Body.String()) - } - var created models.PageLabel - decodeJSONBody(t, rr, &created) - if created.Name != "design" || created.Color != "#3B82F6" || created.WorkspaceID != 1 { - t.Errorf("unexpected label payload: %+v", created) - } -} - -func TestPageLabels_Get_404ForCrossWorkspace(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - seedWorkspaceWithRole(t, db, 2, 999, "Administrator") - - id, _, err := h.repo.Create("secret", "#FF0000", 2) - if err != nil { - t.Fatalf("seed label: %v", err) - } - - // Requesting workspace 1's URL for a workspace-2 label must 404 so - // existence doesn't leak across workspaces. - req := authedRequest(http.MethodGet, "/workspaces/1/page-labels/"+strconv.Itoa(id), userID, nil) - setPath(req, map[string]string{"workspaceId": "1", "labelId": strconv.Itoa(id)}) - rr := httptest.NewRecorder() - h.Get(rr, req) - if rr.Code != http.StatusNotFound { - t.Errorf("cross-workspace get: want 404, got %d body=%s", rr.Code, rr.Body.String()) - } -} - -func TestPageLabels_Delete_CascadesAssignments(t *testing.T) { - h, pageHandler, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - page, err := pageHandler.service.Create(userID, services.CreatePageInput{WorkspaceID: 1, Title: "Home"}) - if err != nil { - t.Fatalf("seed page: %v", err) - } - labelID, _, err := h.repo.Create("urgent", "#FF0000", 1) - if err != nil { - t.Fatalf("seed label: %v", err) - } - if err := h.repo.AddAssignment(page.ID, labelID); err != nil { - t.Fatalf("attach label: %v", err) - } - - // Sanity: assignment present before delete. - attached, _ := h.repo.ListForPage(page.ID) - if len(attached) != 1 { - t.Fatalf("setup: want 1 attached label, got %d", len(attached)) - } - - req := authedRequest(http.MethodDelete, "/workspaces/1/page-labels/"+strconv.Itoa(labelID), userID, nil) - setPath(req, map[string]string{"workspaceId": "1", "labelId": strconv.Itoa(labelID)}) - rr := httptest.NewRecorder() - h.Delete(rr, req) - if rr.Code != http.StatusNoContent { - t.Fatalf("delete: want 204, got %d body=%s", rr.Code, rr.Body.String()) - } - - // FK CASCADE on the junction table removes the assignment row. - after, _ := h.repo.ListForPage(page.ID) - if len(after) != 0 { - t.Errorf("delete should cascade junction: want 0 assignments, got %d", len(after)) - } -} - -func TestPageLabels_AttachDetach_PerPagePermission(t *testing.T) { - h, pageHandler, db := newPageLabelHandler(t) - const editorID = 1 - const viewerID = 2 - seedNegativeTestUser(t, db, editorID) - seedNegativeTestUser(t, db, viewerID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, editorID, "Editor") - assignWorkspaceRole(t, db, 1, viewerID, "Viewer") - - page, err := pageHandler.service.Create(editorID, services.CreatePageInput{WorkspaceID: 1, Title: "Home"}) - if err != nil { - t.Fatalf("seed page: %v", err) - } - labelID, _, err := h.repo.Create("design", "#3B82F6", 1) - if err != nil { - t.Fatalf("seed label: %v", err) - } - - // Viewer cannot attach. - body := map[string]interface{}{"label_id": labelID} - req := authedRequest(http.MethodPost, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/labels", viewerID, body) - setPath(req, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - rr := httptest.NewRecorder() - h.AddToPage(rr, req) - if rr.Code != http.StatusNotFound { - t.Errorf("viewer attach: want 404, got %d body=%s", rr.Code, rr.Body.String()) - } - - // Editor can attach. - req = authedRequest(http.MethodPost, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/labels", editorID, body) - setPath(req, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - rr = httptest.NewRecorder() - h.AddToPage(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("editor attach: want 200, got %d body=%s", rr.Code, rr.Body.String()) - } - var attached []models.PageLabel - decodeJSONBody(t, rr, &attached) - if len(attached) != 1 || attached[0].ID != int(labelID) { - t.Errorf("unexpected attached payload: %+v", attached) - } -} - -func TestPageLabels_AttachRejectsCrossWorkspaceLabel(t *testing.T) { - h, pageHandler, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - seedWorkspaceWithRole(t, db, 2, 999, "Administrator") - - page, err := pageHandler.service.Create(userID, services.CreatePageInput{WorkspaceID: 1, Title: "Home"}) - if err != nil { - t.Fatalf("seed page: %v", err) - } - // Label lives in workspace 2. - otherLabelID, _, err := h.repo.Create("other", "#FF0000", 2) - if err != nil { - t.Fatalf("seed label: %v", err) - } - - body := map[string]interface{}{"label_id": otherLabelID} - req := authedRequest(http.MethodPost, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - rr := httptest.NewRecorder() - h.AddToPage(rr, req) - if rr.Code != http.StatusNotFound { - t.Errorf("cross-workspace attach: want 404, got %d body=%s", rr.Code, rr.Body.String()) - } -} - -func TestPageLabels_List_RequiresWorkspaceView(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - const outsiderID = 2 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, outsiderID) - seedNegativeTestUser(t, db, 999) - // Workspaces are "Viewer-open" until Viewer is explicitly assigned; - // without an explicit Viewer grant the outsider implicitly inherits - // page.view and the 404-not-403 check is vacuous. Assigning Viewer to - // the phantom admin closes Viewer so the outsider genuinely has no grant. - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - assignWorkspaceRole(t, db, 1, 999, "Viewer") - - if _, _, err := h.repo.Create("design", "#3B82F6", 1); err != nil { - t.Fatalf("seed label: %v", err) - } - - // Outsider with no role on workspace 1 gets 404, not the label list. - req := authedRequest(http.MethodGet, "/workspaces/1/page-labels", outsiderID, nil) - setPath(req, map[string]string{"workspaceId": "1"}) - rr := httptest.NewRecorder() - h.List(rr, req) - if rr.Code != http.StatusNotFound { - t.Errorf("outsider list: want 404, got %d body=%s", rr.Code, rr.Body.String()) - } -} - -func TestPageLabels_NameUniquenessPerWorkspace(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - body := map[string]interface{}{"name": "design"} - req := authedRequest(http.MethodPost, "/workspaces/1/page-labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1"}) - rr := httptest.NewRecorder() - h.Create(rr, req) - if rr.Code != http.StatusCreated { - t.Fatalf("first create: want 201, got %d", rr.Code) - } - - req = authedRequest(http.MethodPost, "/workspaces/1/page-labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1"}) - rr = httptest.NewRecorder() - h.Create(rr, req) - if rr.Code != http.StatusConflict { - t.Errorf("duplicate create: want 409, got %d body=%s", rr.Code, rr.Body.String()) - } -} - -func TestPageLabels_PreloadLabelsOnPageDetail(t *testing.T) { - h, pageHandler, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - page, err := pageHandler.service.Create(userID, services.CreatePageInput{WorkspaceID: 1, Title: "Home"}) - if err != nil { - t.Fatalf("seed page: %v", err) - } - labelID, _, err := h.repo.Create("design", "#3B82F6", 1) - if err != nil { - t.Fatalf("seed label: %v", err) - } - if err := h.repo.AddAssignment(page.ID, int(labelID)); err != nil { - t.Fatalf("attach: %v", err) - } - - req := authedRequest(http.MethodGet, "/workspaces/1/pages/"+strconv.Itoa(page.ID), userID, nil) - setPath(req, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - rr := httptest.NewRecorder() - pageHandler.Get(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("page get: want 200, got %d body=%s", rr.Code, rr.Body.String()) - } - - // The page-detail response must include the attached label so the - // frontend doesn't need a second round-trip to render chips. - var resp struct { - Labels []models.PageLabel `json:"labels"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if len(resp.Labels) != 1 || resp.Labels[0].Name != "design" { - t.Errorf("preload missing: %+v", resp.Labels) - } -} - -// Bug-hunt #5: SetForPage used to 500 when a client submitted the same -// label id twice, because the junction's UNIQUE(page_id, page_label_id) -// rejected the duplicate INSERT and the wrapped error reached the -// handler unchanged. ReplaceAssignments now treats the input as a set, -// so the request succeeds and the attached labels list is -// deterministic. -func TestPageLabels_SetForPage_DedupesDuplicateIDs(t *testing.T) { - h, pageHandler, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - page, err := pageHandler.service.Create(userID, services.CreatePageInput{WorkspaceID: 1, Title: "Home"}) - if err != nil { - t.Fatalf("seed page: %v", err) - } - labelID, _, err := h.repo.Create("design", "#3B82F6", 1) - if err != nil { - t.Fatalf("seed label: %v", err) - } - - body := map[string]interface{}{"label_ids": []int{int(labelID), int(labelID)}} - req := authedRequest(http.MethodPut, "/workspaces/1/pages/"+strconv.Itoa(page.ID)+"/labels", userID, body) - setPath(req, map[string]string{"workspaceId": "1", "pageId": strconv.Itoa(page.ID)}) - rr := httptest.NewRecorder() - h.SetForPage(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("set: want 200, got %d body=%s", rr.Code, rr.Body.String()) - } - - var attached []models.PageLabel - decodeJSONBody(t, rr, &attached) - if len(attached) != 1 || attached[0].ID != int(labelID) { - t.Errorf("expected exactly one attached label, got %+v", attached) - } -} - -// Bug-hunt #6: handlers pre-check label-name uniqueness via -// NameExistsInWorkspace, but the check is racy across concurrent -// requests — the loser of the race hits the DB UNIQUE(workspace_id, -// name) constraint directly. Repository must translate that to -// ErrDuplicateEntry so the handler responds 409 instead of 500. -func TestPageLabelRepository_Create_TranslatesUniqueViolation(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - if _, _, err := h.repo.Create("design", "#3B82F6", 1); err != nil { - t.Fatalf("first create: %v", err) - } - _, _, err := h.repo.Create("design", "#3B82F6", 1) - if !errors.Is(err, repository.ErrDuplicateEntry) { - t.Errorf("second create: want ErrDuplicateEntry, got %v", err) - } -} - -// Bug-hunt #6 (rename half): repository.Update must surface the -// workspace-name uniqueness constraint as ErrDuplicateEntry too. -func TestPageLabelRepository_Update_TranslatesUniqueViolation(t *testing.T) { - h, _, db := newPageLabelHandler(t) - const userID = 1 - seedNegativeTestUser(t, db, userID) - seedNegativeTestUser(t, db, 999) - seedWorkspaceWithRole(t, db, 1, userID, "Editor") - - idA, _, err := h.repo.Create("design", "#3B82F6", 1) - if err != nil { - t.Fatalf("create A: %v", err) - } - if _, _, err := h.repo.Create("eng", "#22C55E", 1); err != nil { - t.Fatalf("create B: %v", err) - } - // Rename A onto B's name. - err = h.repo.Update(idA, "eng", "#3B82F6") - if !errors.Is(err, repository.ErrDuplicateEntry) { - t.Errorf("rename onto sibling name: want ErrDuplicateEntry, got %v", err) - } -} diff --git a/internal/handlers/priorities_test.go b/internal/handlers/priorities_test.go deleted file mode 100644 index f72bfd7..0000000 --- a/internal/handlers/priorities_test.go +++ /dev/null @@ -1,422 +0,0 @@ -//go:build test - -package handlers - -import ( - "net/http" - "strings" - "testing" - - "windshift/internal/models" - "windshift/internal/testutils" - "windshift/internal/testutils/factory" -) - -func TestPriorityHandler_Create_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - priority := models.Priority{ - Name: "Super Critical", - Description: "Super critical priority items", - Icon: "flame", - Color: "#ff0000", - SortOrder: 1, - } - - req := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusCreated). - AssertContentType("application/json") - - var response models.Priority - rr.AssertJSONResponse(&response) - - if response.ID == 0 { - t.Error("Expected created priority to have an ID") - } - if response.Name != priority.Name { - t.Errorf("Expected name %s, got %s", priority.Name, response.Name) - } - if response.Icon != priority.Icon { - t.Errorf("Expected icon %s, got %s", priority.Icon, response.Icon) - } - if response.Color != priority.Color { - t.Errorf("Expected color %s, got %s", priority.Color, response.Color) - } -} - -func TestPriorityHandler_Create_ValidationError(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - tests := []struct { - name string - priority models.Priority - expectedErr string - }{ - { - name: "Missing name", - priority: models.Priority{Description: "Description only"}, - expectedErr: "Priority name is required", - }, - { - name: "Empty name", - priority: models.Priority{Name: " ", Description: "Description"}, - expectedErr: "Priority name is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testutils.CreateJSONRequest(t, "POST", "/api/priorities", tt.priority) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - - rr.AssertStatusCode(http.StatusBadRequest) - if !strings.Contains(rr.Body.String(), tt.expectedErr) { - t.Errorf("Expected body to contain %q, got %q", tt.expectedErr, rr.Body.String()) - } - }) - } -} - -func TestPriorityHandler_Create_DuplicateName(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - priority := models.Priority{ - Name: "Duplicate Test", - Color: "#ff0000", - } - - // Create first priority - req1 := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - rr1 := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req1, nil) - rr1.AssertStatusCode(http.StatusCreated) - - // Try to create second with same name - req2 := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - rr2 := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req2, nil) - - rr2.AssertStatusCode(http.StatusConflict) - if !strings.Contains(rr2.Body.String(), "already exists") { - t.Errorf("Expected 'already exists' error, got %s", rr2.Body.String()) - } -} - -func TestPriorityHandler_Create_SetDefault(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create first priority as default - priority1 := models.Priority{ - Name: "First Default", - IsDefault: true, - } - req1 := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority1) - rr1 := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req1, nil) - rr1.AssertStatusCode(http.StatusCreated) - - var created1 models.Priority - rr1.AssertJSONResponse(&created1) - - // Create second priority as default - priority2 := models.Priority{ - Name: "Second Default", - IsDefault: true, - } - req2 := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority2) - rr2 := testutils.ExecuteAuthenticatedRequest(t, handler.Create, req2, nil) - rr2.AssertStatusCode(http.StatusCreated) - - var created2 models.Priority - rr2.AssertJSONResponse(&created2) - - // Verify the second one is the only default now - if !created2.IsDefault { - t.Error("Expected second priority to be default") - } - - // Verify first one is no longer default - getReq := testutils.CreateJSONRequest(t, "GET", "/api/priorities/"+testutils.IntToString(created1.ID), nil) - getReq.SetPathValue("id", testutils.IntToString(created1.ID)) - getRR := testutils.ExecuteAuthenticatedRequest(t, handler.Get, getReq, nil) - - var updated1 models.Priority - getRR.AssertJSONResponse(&updated1) - - if updated1.IsDefault { - t.Error("Expected first priority to no longer be default") - } -} - -func TestPriorityHandler_GetAll_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create test priorities - priorities := []models.Priority{ - {Name: "Low", SortOrder: 3}, - {Name: "Medium", SortOrder: 2}, - {Name: "High", SortOrder: 1}, - } - - for _, p := range priorities { - req := testutils.CreateJSONRequest(t, "POST", "/api/priorities", p) - testutils.ExecuteAuthenticatedRequest(t, handler.Create, req, nil) - } - - req := testutils.CreateJSONRequest(t, "GET", "/api/priorities", nil) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.GetAll, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response []models.Priority - rr.AssertJSONResponse(&response) - - // Should include at least our 3 created types (may also have default priorities) - if len(response) < 3 { - t.Errorf("Expected at least 3 priorities, got %d", len(response)) - } -} - -func TestPriorityHandler_Get_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create test priority - priority := models.Priority{ - Name: "Test Get Priority", - Description: "Test description", - Icon: "star", - Color: "#00ff00", - SortOrder: 5, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Priority - createRR.AssertJSONResponse(&created) - - // Get the priority - req := testutils.CreateJSONRequest(t, "GET", "/api/priorities/"+testutils.IntToString(created.ID), nil) - req.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.Priority - rr.AssertJSONResponse(&response) - - if response.ID != created.ID { - t.Errorf("Expected ID %d, got %d", created.ID, response.ID) - } - if response.Name != priority.Name { - t.Errorf("Expected name %s, got %s", priority.Name, response.Name) - } - if response.Description != priority.Description { - t.Errorf("Expected description %s, got %s", priority.Description, response.Description) - } -} - -func TestPriorityHandler_Get_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - req := testutils.CreateJSONRequest(t, "GET", "/api/priorities/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Get, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestPriorityHandler_Update_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create test priority - priority := models.Priority{ - Name: "Original Priority", - Description: "Original description", - Icon: "circle", - Color: "#111111", - SortOrder: 1, - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Priority - createRR.AssertJSONResponse(&created) - - // Update the priority - updateData := models.Priority{ - Name: "Updated Priority", - Description: "Updated description", - Icon: "flag", - Color: "#222222", - SortOrder: 2, - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/priorities/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusOK). - AssertContentType("application/json") - - var response models.Priority - rr.AssertJSONResponse(&response) - - if response.Name != "Updated Priority" { - t.Errorf("Expected name 'Updated Priority', got %s", response.Name) - } - if response.Description != "Updated description" { - t.Errorf("Expected description 'Updated description', got %s", response.Description) - } - if response.Icon != "flag" { - t.Errorf("Expected icon 'flag', got %s", response.Icon) - } - if response.Color != "#222222" { - t.Errorf("Expected color '#222222', got %s", response.Color) - } -} - -func TestPriorityHandler_Update_ValidationError(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create test priority - priority := models.Priority{ - Name: "Test Priority", - Color: "#333333", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Priority - createRR.AssertJSONResponse(&created) - - // Try to update with missing name - updateData := models.Priority{ - Name: "", - Color: "#444444", - } - - updateReq := testutils.CreateJSONRequest(t, "PUT", "/api/priorities/"+testutils.IntToString(created.ID), updateData) - updateReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Update, updateReq, nil) - - rr.AssertStatusCode(http.StatusBadRequest) -} - -func TestPriorityHandler_Delete_Success(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create test priority - priority := models.Priority{ - Name: "To Delete", - Color: "#555555", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Priority - createRR.AssertJSONResponse(&created) - - // Delete the priority - deleteReq := testutils.CreateJSONRequest(t, "DELETE", "/api/priorities/"+testutils.IntToString(created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, deleteReq, nil) - - rr.AssertStatusCode(http.StatusNoContent) - - // Verify priority is deleted - getReq := testutils.CreateJSONRequest(t, "GET", "/api/priorities/"+testutils.IntToString(created.ID), nil) - getReq.SetPathValue("id", testutils.IntToString(created.ID)) - getRR := testutils.ExecuteAuthenticatedRequest(t, handler.Get, getReq, nil) - - getRR.AssertStatusCode(http.StatusNotFound) -} - -func TestPriorityHandler_Delete_NotFound(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - handler := NewPriorityHandler(tdb.GetDatabase()) - - req := testutils.CreateJSONRequest(t, "DELETE", "/api/priorities/99999", nil) - req.SetPathValue("id", "99999") - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, req, nil) - - rr.AssertStatusCode(http.StatusNotFound) -} - -func TestPriorityHandler_Delete_InUse(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - defer tdb.Close() - - data := tdb.SeedTestData(t) - handler := NewPriorityHandler(tdb.GetDatabase()) - - // Create a new priority and use it in an item - priority := models.Priority{ - Name: "In Use Priority", - Color: "#666666", - } - - createReq := testutils.CreateJSONRequest(t, "POST", "/api/priorities", priority) - createRR := testutils.ExecuteAuthenticatedRequest(t, handler.Create, createReq, nil) - - var created models.Priority - createRR.AssertJSONResponse(&created) - - // Create an item using this priority through the production create path - f := factory.NewTestFactory(tdb.GetDatabase()) - _, err := f.CreateItem(factory.CreateItemOpts{ - WorkspaceID: data.WorkspaceID, - Title: "Test Item", - StatusID: &data.StatusID, - PriorityID: &created.ID, - }) - if err != nil { - t.Fatalf("Failed to create test item: %v", err) - } - - // Try to delete the priority - deleteReq := testutils.CreateJSONRequest(t, "DELETE", "/api/priorities/"+testutils.IntToString(created.ID), nil) - deleteReq.SetPathValue("id", testutils.IntToString(created.ID)) - rr := testutils.ExecuteAuthenticatedRequest(t, handler.Delete, deleteReq, nil) - - rr.AssertStatusCode(http.StatusConflict) - if !strings.Contains(rr.Body.String(), "Cannot delete priority") { - t.Errorf("Expected 'Cannot delete priority' error, got %s", rr.Body.String()) - } -} diff --git a/internal/repository/page_labels_unique_test.go b/internal/repository/page_labels_unique_test.go new file mode 100644 index 0000000..12a1351 --- /dev/null +++ b/internal/repository/page_labels_unique_test.go @@ -0,0 +1,73 @@ +package repository + +import ( + "errors" + "testing" + + "windshift/internal/testutils" +) + +func newPageLabelUniqueRepository(t *testing.T) *PageLabelRepository { + t.Helper() + db := testutils.CreateTestDB(t, false) + // PostgreSQL cleanup is already registered by CreateTestDB. + if !testutils.IsPostgres() { + t.Cleanup(func() { _ = db.Close() }) + } + // Repository-only constraint tests intentionally bypass HTTP setup. + if _, err := db.Exec(`INSERT INTO workspaces (id, name, key) VALUES (1, 'Labels', 'LBL')`); err != nil { + t.Fatal(err) + } + return NewPageLabelRepository(db.Database) +} + +func TestPageLabelRepository_Delete_RemovesAssignmentRows(t *testing.T) { + repo := newPageLabelUniqueRepository(t) + pageID := testutils.InsertID(t, repo.db, `INSERT INTO pages (workspace_id, title, slug, created_by) VALUES (1, 'Page', 'page', 1)`) + labelID, _, err := repo.Create("design", "#3B82F6", 1) + if err != nil { + t.Fatal(err) + } + if _, err := repo.db.Exec(`INSERT INTO page_label_assignments (page_id, page_label_id) VALUES (?, ?)`, pageID, labelID); err != nil { + t.Fatal(err) + } + assertCount := func(want int) { + t.Helper() + var count int + if err := repo.db.QueryRow(`SELECT COUNT(*) FROM page_label_assignments WHERE page_id = ? AND page_label_id = ?`, pageID, labelID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("assignment rows: got %d, want %d", count, want) + } + } + assertCount(1) + if err := repo.Delete(labelID); err != nil { + t.Fatal(err) + } + assertCount(0) +} + +func TestPageLabelRepository_Create_TranslatesUniqueViolation(t *testing.T) { + repo := newPageLabelUniqueRepository(t) + if _, _, err := repo.Create("design", "#3B82F6", 1); err != nil { + t.Fatal(err) + } + if _, _, err := repo.Create("design", "#3B82F6", 1); !errors.Is(err, ErrDuplicateEntry) { + t.Fatalf("duplicate create: want ErrDuplicateEntry, got %v", err) + } +} + +func TestPageLabelRepository_Update_TranslatesUniqueViolation(t *testing.T) { + repo := newPageLabelUniqueRepository(t) + id, _, err := repo.Create("design", "#3B82F6", 1) + if err != nil { + t.Fatal(err) + } + if _, _, err := repo.Create("eng", "#22C55E", 1); err != nil { + t.Fatal(err) + } + if err := repo.Update(id, "eng", "#3B82F6"); !errors.Is(err, ErrDuplicateEntry) { + t.Fatalf("duplicate rename: want ErrDuplicateEntry, got %v", err) + } +} diff --git a/internal/restapi/v1/handlers/openapi_test.go b/internal/restapi/v1/handlers/openapi_test.go deleted file mode 100644 index f265ee6..0000000 --- a/internal/restapi/v1/handlers/openapi_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestOpenAPISpecJSON_ServesValidJSON(t *testing.T) { - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/rest/api/v1/openapi.json", nil) - - OpenAPISpecJSON(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200", rr.Code) - } - if got := rr.Header().Get("Content-Type"); got != "application/json" { - t.Errorf("content-type: got %q, want application/json", got) - } - if rr.Header().Get("Cache-Control") == "" { - t.Error("expected Cache-Control header to be set") - } - body := rr.Body.Bytes() - if len(body) == 0 { - t.Fatal("body is empty — embed may have failed") - } - var doc map[string]any - if err := json.Unmarshal(body, &doc); err != nil { - t.Fatalf("body is not valid JSON: %v", err) - } - if _, ok := doc["openapi"]; !ok { - t.Error("expected an `openapi` field in the spec document") - } -} - -func TestOpenAPISpecYAML_ServesContent(t *testing.T) { - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/rest/api/v1/openapi.yaml", nil) - - OpenAPISpecYAML(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200", rr.Code) - } - if got := rr.Header().Get("Content-Type"); got != "application/yaml" { - t.Errorf("content-type: got %q, want application/yaml", got) - } - if rr.Body.Len() == 0 { - t.Fatal("body is empty — embed may have failed") - } -} diff --git a/internal/scheduler/scheduler_lifecycle_test.go b/internal/scheduler/scheduler_lifecycle_test.go index 04dbea3..5eb14bc 100644 --- a/internal/scheduler/scheduler_lifecycle_test.go +++ b/internal/scheduler/scheduler_lifecycle_test.go @@ -1,6 +1,7 @@ package scheduler import ( + "context" "testing" "time" @@ -14,6 +15,10 @@ func (noopSMTPSender) SendBatchedNotifications(string, string, []models.Notifica return nil } +func (noopSMTPSender) SendBatchedNotificationsContext(context.Context, string, string, []models.Notification) error { + return nil +} + func (noopSMTPSender) IsSMTPConfigured() bool { return false } diff --git a/internal/restapi/v1/handlers/actions_capability_test.go b/internal/services/action_definition_capability_test.go similarity index 76% rename from internal/restapi/v1/handlers/actions_capability_test.go rename to internal/services/action_definition_capability_test.go index 553b8be..9389f9f 100644 --- a/internal/restapi/v1/handlers/actions_capability_test.go +++ b/internal/services/action_definition_capability_test.go @@ -1,4 +1,4 @@ -package handlers +package services_test import ( "path/filepath" @@ -7,9 +7,10 @@ import ( "windshift/internal/database" "windshift/internal/models" "windshift/internal/repository" + "windshift/internal/services" ) -func TestActionHandlerResolvesCapabilitiesByType(t *testing.T) { +func TestActionDefinitionServiceResolvesCapabilitiesByType(t *testing.T) { db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "v1-actions.db")) if err != nil { t.Fatalf("NewSQLiteDB: %v", err) @@ -30,12 +31,12 @@ func TestActionHandlerResolvesCapabilitiesByType(t *testing.T) { if _, err := repo.CreateCapabilityWithWorkspaces(capability, nil); err != nil { t.Fatalf("CreateCapabilityWithWorkspaces: %v", err) } - handler := &ActionHandler{repo: repo} + service := services.NewActionDefinitionService(repo, nil) - if !handler.HasCapabilityOfType(123, capability.ID, models.CapabilityDockerEnvironment) { + if !service.HasCapabilityOfType(123, capability.ID, models.CapabilityDockerEnvironment) { t.Fatal("matching capability type was rejected") } - if handler.HasCapabilityOfType(123, capability.ID, models.CapabilityHTTPClient) { + if service.HasCapabilityOfType(123, capability.ID, models.CapabilityHTTPClient) { t.Fatal("docker capability was accepted as an HTTP capability") } } diff --git a/internal/services/hierarchy_test.go b/internal/services/hierarchy_test.go index 62804dc..f61875e 100644 --- a/internal/services/hierarchy_test.go +++ b/internal/services/hierarchy_test.go @@ -88,7 +88,7 @@ func setupHierarchyTestEnv(t *testing.T, db database.Database) hierarchyTestEnv Description: description, StatusID: statusRef, ItemTypeID: itemType, - IsTask: true, + IsTask: false, ParentID: parentID, CreatorID: &creator, }) @@ -504,7 +504,7 @@ func TestHierarchyService_GetEffectiveProject(t *testing.T) { WorkspaceID: env.WorkspaceID, Title: "Item With Project", Description: "Description", - IsTask: true, + IsTask: false, StatusID: &env.StatusID, ProjectID: &projectID, }) @@ -541,7 +541,7 @@ func TestHierarchyService_GetEffectiveProject(t *testing.T) { WorkspaceID: env.WorkspaceID, Title: "Parent With Project", Description: "Description", - IsTask: true, + IsTask: false, StatusID: &env.StatusID, ProjectID: &projectID, }) @@ -555,7 +555,7 @@ func TestHierarchyService_GetEffectiveProject(t *testing.T) { WorkspaceID: env.WorkspaceID, Title: "Child Item", Description: "Description", - IsTask: true, + IsTask: false, StatusID: &env.StatusID, ParentID: &parentID, }) diff --git a/internal/services/item_workspace_move_service_test.go b/internal/services/item_workspace_move_service_test.go index bb892ee..baacc4a 100644 --- a/internal/services/item_workspace_move_service_test.go +++ b/internal/services/item_workspace_move_service_test.go @@ -145,7 +145,7 @@ func TestItemWorkspaceMovePreviewsAndCommitsExactPolicy(t *testing.T) { destinationTotalBefore := workspaceTotal(destinationWorkspaceID) service := NewItemWorkspaceMoveService(db) - preview, err := service.Preview(itemID, ItemWorkspaceMoveInput{DestinationWorkspaceID: destinationWorkspaceID}) + preview, err := service.Preview(itemID, seed.UserID, ItemWorkspaceMoveInput{DestinationWorkspaceID: destinationWorkspaceID}) if err != nil { t.Fatalf("preview move: %v", err) } diff --git a/internal/services/mention_service_test.go b/internal/services/mention_service_test.go index b2bfe18..056f723 100644 --- a/internal/services/mention_service_test.go +++ b/internal/services/mention_service_test.go @@ -66,7 +66,7 @@ func setupMentionTestEnv(t *testing.T, db database.Database) mentionTestEnv { WorkspaceID: workspaceID, Title: "Test Item", Description: "Item description", - IsTask: true, + IsTask: false, StatusID: &statusID, }) if err != nil { diff --git a/internal/services/notification_service_test.go b/internal/services/notification_service_test.go index c84c235..ed5b66c 100644 --- a/internal/services/notification_service_test.go +++ b/internal/services/notification_service_test.go @@ -60,7 +60,8 @@ func TestUnreadEmailBatchesReauthorizeWorkspaceNotifications(t *testing.T) { if err != nil { t.Fatalf("email batches before revocation: %v", err) } - assertNotificationTitles(t, before["notification-recipient@example.com"], "system notice", "allowed workspace") + // Email batches are oldest-first, with the ID breaking equal timestamps. + assertNotificationTitles(t, before["notification-recipient@example.com"], "allowed workspace", "system notice") if _, err := roles.RevokeFromUser(userID, allowedWorkspaceID, viewerRoleID); err != nil { t.Fatalf("revoke workspace role: %v", err) @@ -108,7 +109,7 @@ func TestUnreadEmailBatchesReauthorizeGroupDerivedWorkspaceNotifications(t *test if err != nil { t.Fatalf("email batches before group revocation: %v", err) } - assertNotificationTitles(t, before["notification-group-recipient@example.com"], "system notice", "group workspace") + assertNotificationTitles(t, before["notification-group-recipient@example.com"], "group workspace", "system notice") if _, err := env.db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", groupID, userID); err != nil { t.Fatalf("remove group membership: %v", err) diff --git a/internal/services/push_service_core_regression_test.go b/internal/services/push_service_core_regression_test.go index 68c8058..6949bda 100644 --- a/internal/services/push_service_core_regression_test.go +++ b/internal/services/push_service_core_regression_test.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "path/filepath" "strings" "sync" "sync/atomic" @@ -16,38 +15,23 @@ import ( "windshift/internal/database" "windshift/internal/models" "windshift/internal/repository" + "windshift/internal/testutils" webpush "github.com/SherClockHolmes/webpush-go" ) func newPushTestDB(t *testing.T) database.Database { t.Helper() - db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "push.db")) - if err != nil { - t.Fatalf("new SQLite database: %v", err) - } - statements := []string{ - `CREATE TABLE users (id INTEGER PRIMARY KEY)`, - `CREATE TABLE push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - endpoint TEXT NOT NULL UNIQUE, - auth_key TEXT NOT NULL, - p256dh_key TEXT NOT NULL, - user_agent TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME, - revoked_at DATETIME - )`, - `INSERT INTO users (id) VALUES (1), (2), (3)`, - } - for _, statement := range statements { - if _, err := db.ExecWrite(statement); err != nil { - _ = db.Close() - t.Fatalf("initialize push database: %v", err) - } + // Push authorization reads current user state. Use the production schema + // instead of a partial users table that can silently suppress delivery. + tdb := testutils.CreateTestDB(t, true) + db := tdb.DB + if tdb.Engine == "sqlite" { + t.Cleanup(func() { _ = tdb.Close() }) + } + if _, err := db.ExecWrite(`INSERT INTO users (id,email,username,first_name,last_name,is_active) VALUES (2,'push2@example.test','push2','Push','Two',true),(3,'push3@example.test','push3','Push','Three',true)`); err != nil { + t.Fatalf("seed push users: %v", err) } - t.Cleanup(func() { _ = db.Close() }) return db } diff --git a/internal/services/scm_action_trigger_contract_test.go b/internal/services/scm_action_trigger_contract_test.go index 9c752d4..fbc50f5 100644 --- a/internal/services/scm_action_trigger_contract_test.go +++ b/internal/services/scm_action_trigger_contract_test.go @@ -3,6 +3,7 @@ package services import ( + "context" "database/sql" "strings" "testing" @@ -72,7 +73,7 @@ func TestSCMActionTriggersRequireExplicitActor(t *testing.T) { ID: actionID, WorkspaceID: data.WorkspaceID, Name: string(trigger), IsEnabled: true, TriggerType: trigger, } - err := service.executeActionForEvent(action, &models.ActionEvent{ + err := service.executeActionForEvent(context.Background(), action, &models.ActionEvent{ EventType: trigger, WorkspaceID: data.WorkspaceID, NewValues: map[string]any{"repo.workspace_repository_id": 17}, }, nil, "") diff --git a/internal/tui/data/comment_client_test.go b/internal/tui/data/comment_client_test.go index c4e5c31..72fe765 100644 --- a/internal/tui/data/comment_client_test.go +++ b/internal/tui/data/comment_client_test.go @@ -5,42 +5,30 @@ import ( "net/http" "net/http/httptest" "testing" - "time" ) func TestClientGetCommentsFollowsAllPages(t *testing.T) { requestedPages := make([]string, 0, 2) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/rest/api/v1/items/840/comments" { + if r.URL.Path != "/rest/api/v2/items/840/comments" { http.NotFound(w, r) return } if got := r.Header.Get("Authorization"); got != "Bearer test-token" { t.Fatalf("Authorization = %q, want bearer token", got) } - if got := r.URL.Query().Get("expand"); got != "author" { - t.Fatalf("expand = %q, want author", got) - } - if got := r.URL.Query().Get("limit"); got != "100" { - t.Fatalf("limit = %q, want 100", got) + if got := r.URL.Query().Get("page_size"); got != "100" { + t.Fatalf("page_size = %q, want 100", got) } - page := r.URL.Query().Get("page") + page := r.URL.Query().Get("cursor") requestedPages = append(requestedPages, page) - response := v1CommentsPage{ - Pagination: v1PaginationMeta{Page: 1, Limit: 100, Total: 3, TotalPages: 2}, - } + var response any switch page { - case "1": - response.Data = []v1CommentResponse{ - {ID: 3, ItemID: 840, Content: "newest", CreatedAt: time.Unix(3, 0).UTC()}, - {ID: 2, ItemID: 840, Content: "middle", CreatedAt: time.Unix(2, 0).UTC()}, - } - case "2": - response.Pagination.Page = 2 - response.Data = []v1CommentResponse{ - {ID: 1, ItemID: 840, Content: "oldest", CreatedAt: time.Unix(1, 0).UTC()}, - } + case "": + response = map[string]any{"data": map[string]any{"comments": []map[string]any{{"id": 3, "item_id": 840, "content": "newest"}, {"id": 2, "item_id": 840, "content": "middle"}}, "has_more": true, "next_cursor": "older"}} + case "older": + response = map[string]any{"data": map[string]any{"comments": []map[string]any{{"id": 1, "item_id": 840, "content": "oldest"}}, "has_more": false}} default: t.Fatalf("unexpected page %q", page) } @@ -63,8 +51,8 @@ func TestClientGetCommentsFollowsAllPages(t *testing.T) { t.Fatalf("comments = %+v, want IDs 3, 2, 1", comments) } if len(requestedPages) != 2 || - requestedPages[0] != "1" || - requestedPages[1] != "2" { - t.Fatalf("requested pages = %v, want [1 2]", requestedPages) + requestedPages[0] != "" || + requestedPages[1] != "older" { + t.Fatalf("requested cursors = %v, want initial then older", requestedPages) } } diff --git a/internal/validation/item_field_validator_test.go b/internal/validation/item_field_validator_test.go index 478f3e2..e8fe28c 100644 --- a/internal/validation/item_field_validator_test.go +++ b/internal/validation/item_field_validator_test.go @@ -260,7 +260,7 @@ func TestItemFieldValidator(t *testing.T) { Title: "Test Item", } - err := validator.ValidateCreateRequest(item) + err := validator.ValidateCreateRequest(item, testData.UserID) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -272,7 +272,7 @@ func TestItemFieldValidator(t *testing.T) { Title: "", } - err := validator.ValidateCreateRequest(item) + err := validator.ValidateCreateRequest(item, testData.UserID) if err == nil { t.Error("Expected validation error for missing title") } @@ -284,7 +284,7 @@ func TestItemFieldValidator(t *testing.T) { Title: "Test Item", } - err := validator.ValidateCreateRequest(item) + err := validator.ValidateCreateRequest(item, testData.UserID) if err == nil { t.Error("Expected validation error for invalid workspace") } diff --git a/internal/wscli/comment_client_test.go b/internal/wscli/comment_client_test.go index ee64b60..34e13da 100644 --- a/internal/wscli/comment_client_test.go +++ b/internal/wscli/comment_client_test.go @@ -9,25 +9,22 @@ import ( func TestClient_GetCommentsFollowsAllPages(t *testing.T) { requestedPages := make([]string, 0, 2) client, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/rest/api/v1/items/840/comments" { + if r.URL.Path != "/rest/api/v2/items/840/comments" { http.NotFound(w, r) return } requestedPages = append(requestedPages, r.URL.RawQuery) - page := r.URL.Query().Get("page") - response := PaginatedResponse[Comment]{ - Pagination: PaginationMeta{Page: 1, Limit: 100, Total: 3, TotalPages: 2}, - } - switch page { - case "1": - response.Data = []Comment{{ID: 3}, {ID: 2}} - case "2": - response.Pagination.Page = 2 - response.Data = []Comment{{ID: 1}} + cursor := r.URL.Query().Get("cursor") + var response map[string]any + switch cursor { + case "": + response = map[string]any{"comments": []Comment{{ID: 3}, {ID: 2}}, "has_more": true, "next_cursor": "older"} + case "older": + response = map[string]any{"comments": []Comment{{ID: 1}}, "has_more": false} default: - t.Fatalf("unexpected page %q", page) + t.Errorf("unexpected cursor %q", cursor) } - _ = json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(map[string]any{"data": response}) }) comments, err := client.GetComments(840) @@ -41,8 +38,8 @@ func TestClient_GetCommentsFollowsAllPages(t *testing.T) { t.Fatalf("comments = %+v, want IDs 3, 2, 1", comments) } if len(requestedPages) != 2 || - requestedPages[0] != "page=1&limit=100" || - requestedPages[1] != "page=2&limit=100" { + requestedPages[0] != "page_size=100" || + requestedPages[1] != "page_size=100&cursor=older" { t.Fatalf("requested pages = %v", requestedPages) } } diff --git a/internal/wscli/item_attachment_test.go b/internal/wscli/item_attachment_test.go index 286c491..0d04c91 100644 --- a/internal/wscli/item_attachment_test.go +++ b/internal/wscli/item_attachment_test.go @@ -24,9 +24,9 @@ func seedFile(t *testing.T, dir, name, contents string) string { return path } -// UploadItemAttachment must hit the v1 item route, send a bearer token, put +// UploadItemAttachment must hit the v2 item route, send a bearer token, put // the bytes in a `file` part under the base filename, and decode the shared -// {success,message,attachment} envelope. +// data envelope. func TestClient_UploadItemAttachment_HappyPath(t *testing.T) { var gotPath, gotAuth, gotFilename string var gotBytes []byte @@ -59,9 +59,7 @@ func TestClient_UploadItemAttachment_HappyPath(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "success": true, - "message": "Attachment uploaded", - "attachment": Attachment{ + "data": Attachment{ ID: 77, Filename: "stored-abc.png", OriginalFilename: gotFilename, @@ -76,7 +74,7 @@ func TestClient_UploadItemAttachment_HappyPath(t *testing.T) { t.Fatalf("UploadItemAttachment: %v", err) } - if gotPath != "/rest/api/v1/items/1181/attachments" { + if gotPath != "/rest/api/v2/items/1181/attachments" { t.Errorf("upload path: %q", gotPath) } if gotAuth != "Bearer ws_test_token" { @@ -93,15 +91,14 @@ func TestClient_UploadItemAttachment_HappyPath(t *testing.T) { } } -// A v1-shaped error body must come back as an *APIError carrying the status, +// A v2-shaped error body must come back as an *APIError carrying the status, // so translateItemAttachmentError can recognise a 404. func TestClient_UploadItemAttachment_APIErrorEnvelope(t *testing.T) { c, _ := newTestPageClient(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{ - "code": "ITEM_NOT_FOUND", - "message": "Item not found", + "error": map[string]any{"code": "ITEM_NOT_FOUND", "message": "Item not found"}, }) }) @@ -250,7 +247,7 @@ func TestMultipartEnvelopeSize(t *testing.T) { body, _ := io.ReadAll(r.Body) seen = int64(len(body)) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"attachment":{"id":1}}`)) + _, _ = w.Write([]byte(`{"data":{"id":1}}`)) }) if _, err := c.UploadItemAttachment(1, filename, strings.NewReader(payload)); err != nil { t.Fatalf("upload: %v", err) diff --git a/internal/wscli/page_attachment_test.go b/internal/wscli/page_attachment_test.go index d3bb715..795f6d6 100644 --- a/internal/wscli/page_attachment_test.go +++ b/internal/wscli/page_attachment_test.go @@ -91,7 +91,7 @@ func TestRewriteImageRefs(t *testing.T) { // uploadAndRewrite drives the full flow against a stub server that // records each multipart upload, asserts entity discrimination, and -// returns the legacy {success,message,attachment} envelope. The summary +// returns the canonical v2 data envelope. The summary // line reports the upload/skip counts the user will see. func TestUploadAndRewrite_FullFlow(t *testing.T) { baseDir := t.TempDir() @@ -109,12 +109,8 @@ func TestUploadAndRewrite_FullFlow(t *testing.T) { c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") - // entity_type/entity_id come in via the URL query because the - // v1 wrapper appends them — but our test client posts directly - // against the server stub, so we read them from the request's - // form values after parsing the multipart. The CLI doesn't set - // them itself; only the v1 wrapper does. In this isolation - // test we just assert the multipart parts the CLI sends. + // The canonical route identifies the page. Assert the multipart + // parts sent by the CLI independently of transport internals. mediaType, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) if !strings.HasPrefix(mediaType, "multipart/") { t.Errorf("content-type: want multipart/*, got %q", mediaType) @@ -130,6 +126,9 @@ func TestUploadAndRewrite_FullFlow(t *testing.T) { if err != nil { t.Fatalf("read part: %v", err) } + if part.FormName() == "entity_type" { + t.Error("unexpected legacy entity_type multipart field") + } if part.FormName() != "file" { continue } @@ -137,12 +136,12 @@ func TestUploadAndRewrite_FullFlow(t *testing.T) { gotFileBytes, _ = io.ReadAll(part) } gotEntityType = r.URL.Query().Get("entity_type") - _ = gotEntityType // CLI does not set this — see comment above + if gotEntityType != "" { + t.Errorf("unexpected legacy entity_type query: %q", gotEntityType) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "success": true, - "message": "ok", - "attachment": Attachment{ + "data": Attachment{ ID: nextID, Filename: "stored.png", OriginalFilename: gotFilename, @@ -169,7 +168,7 @@ func TestUploadAndRewrite_FullFlow(t *testing.T) { t.Errorf("remote ref must remain untouched: %q", rewritten) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7/attachments" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7/attachments" { t.Errorf("upload path: %q", gotPath) } if gotAuth != "Bearer ws_test_token" { @@ -247,15 +246,13 @@ func TestTranslatePagePermissionError(t *testing.T) { } // UploadPageAttachment reports a useful error when the server emits a -// legacy {success:false,message:"..."} envelope (the cookie-auth -// handler's validation path). -func TestClient_UploadPageAttachment_LegacyErrorEnvelope(t *testing.T) { +// canonical v2 error envelope for rejected file extensions. +func TestClient_UploadPageAttachment_V2ErrorEnvelope(t *testing.T) { c, _ := newTestPageClient(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "message": "File extension .svg is not allowed", + "error": map[string]any{"code": "invalid_request", "message": "File extension .svg is not allowed"}, }) }) _, err := c.UploadPageAttachment(1, 2, "img.svg", strings.NewReader("")) diff --git a/internal/wscli/page_client_test.go b/internal/wscli/page_client_test.go index 021b42e..3bcc220 100644 --- a/internal/wscli/page_client_test.go +++ b/internal/wscli/page_client_test.go @@ -27,7 +27,7 @@ func newTestPageClient(t *testing.T, handler http.HandlerFunc) (*Client, *httpte return c, srv } -// readJSONBody is a small helper for asserting POST/PUT bodies. +// readJSONBody is a small helper for asserting JSON mutation bodies. func readJSONBody(t *testing.T, r *http.Request, dst interface{}) { t.Helper() body, err := io.ReadAll(r.Body) @@ -50,7 +50,7 @@ func TestClient_ListPages_PathAndAuth(t *testing.T) { gotAuth = r.Header.Get("Authorization") gotContentType = r.Header.Get("Content-Type") gotAccept = r.Header.Get("Accept") - _ = json.NewEncoder(w).Encode(PageListResponse{Items: []Page{ + _ = json.NewEncoder(w).Encode(map[string]any{"data": []Page{ {ID: 1, Title: "First", WorkspaceID: 42}, {ID: 2, Title: "Second", WorkspaceID: 42}, }}) @@ -63,8 +63,8 @@ func TestClient_ListPages_PathAndAuth(t *testing.T) { if gotMethod != http.MethodGet { t.Errorf("method: want GET, got %s", gotMethod) } - if gotPath != "/rest/api/v1/workspaces/42/pages" { - t.Errorf("path: want /rest/api/v1/workspaces/42/pages, got %s", gotPath) + if gotPath != "/rest/api/v2/workspaces/42/pages" { + t.Errorf("path: want /rest/api/v2/workspaces/42/pages, got %s", gotPath) } if gotAuth != "Bearer ws_test_token" { t.Errorf("auth: want Bearer ws_test_token, got %s", gotAuth) @@ -85,7 +85,7 @@ func TestClient_GetPage_Path(t *testing.T) { c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotMethod = r.Method - _ = json.NewEncoder(w).Encode(Page{ID: 7, Title: "Onboarding", WorkspaceID: 42}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7, Title: "Onboarding", WorkspaceID: 42}}) }) page, err := c.GetPage(42, 7) @@ -95,7 +95,7 @@ func TestClient_GetPage_Path(t *testing.T) { if gotMethod != http.MethodGet { t.Errorf("method: want GET, got %s", gotMethod) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7" { t.Errorf("path: got %s", gotPath) } if page.Title != "Onboarding" { @@ -111,7 +111,7 @@ func TestClient_CreatePage_Body(t *testing.T) { gotPath = r.URL.Path readJSONBody(t, r, &gotBody) w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(Page{ID: 99, Title: gotBody.Title}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 99, Title: gotBody.Title}}) }) parentID := 5 @@ -122,7 +122,7 @@ func TestClient_CreatePage_Body(t *testing.T) { if gotMethod != http.MethodPost { t.Errorf("method: want POST, got %s", gotMethod) } - if gotPath != "/rest/api/v1/workspaces/42/pages" { + if gotPath != "/rest/api/v2/workspaces/42/pages" { t.Errorf("path: got %s", gotPath) } if gotBody.Title != "New" || gotBody.Content != "body" { @@ -143,7 +143,7 @@ func TestClient_UpdatePage_Body(t *testing.T) { gotMethod = r.Method gotPath = r.URL.Path readJSONBody(t, r, &gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7}}) }) title := "Edited" @@ -152,10 +152,10 @@ func TestClient_UpdatePage_Body(t *testing.T) { if err != nil { t.Fatalf("UpdatePage: %v", err) } - if gotMethod != http.MethodPut { - t.Errorf("method: want PUT, got %s", gotMethod) + if gotMethod != http.MethodPatch { + t.Errorf("method: want PATCH, got %s", gotMethod) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7" { t.Errorf("path: got %s", gotPath) } if gotBody.Title == nil || *gotBody.Title != "Edited" { @@ -172,13 +172,13 @@ func TestClient_MovePage_RootViaNullParent(t *testing.T) { c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path readJSONBody(t, r, &gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7, ParentID: nil}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7, ParentID: nil}}) }) if _, err := c.MovePage(42, 7, nil, nil, nil); err != nil { t.Fatalf("MovePage(root): %v", err) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7/move" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7/move" { t.Errorf("path: got %s", gotPath) } if gotBody.ParentID != nil { @@ -190,7 +190,7 @@ func TestClient_MovePage_NonRoot(t *testing.T) { var gotBody PageMoveRequest c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { readJSONBody(t, r, &gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7}}) }) parent := 11 @@ -206,7 +206,7 @@ func TestClient_MovePage_CrossWorkspace(t *testing.T) { var gotBody PageMoveRequest c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { readJSONBody(t, r, &gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7, WorkspaceID: 77}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7, WorkspaceID: 77}}) }) destination := 77 @@ -232,7 +232,7 @@ func TestClient_ArchivePage_Path(t *testing.T) { if gotMethod != http.MethodDelete { t.Errorf("method: want DELETE, got %s", gotMethod) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7" { t.Errorf("path: got %s", gotPath) } } @@ -241,8 +241,9 @@ func TestClient_GetPageHistory_Path(t *testing.T) { var gotPath string c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path - _ = json.NewEncoder(w).Encode(PageHistoryResponse{ - Items: []PageRevision{{ID: 1, RevisionNumber: 1}, {ID: 2, RevisionNumber: 2}}, + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []PageRevision{{ID: 1, RevisionNumber: 1}, {ID: 2, RevisionNumber: 2}}, + "pagination": PaginationMeta{Page: 1, PageSize: 50, TotalItems: 2, TotalPages: 1}, }) }) @@ -250,7 +251,7 @@ func TestClient_GetPageHistory_Path(t *testing.T) { if err != nil { t.Fatalf("GetPageHistory: %v", err) } - if gotPath != "/rest/api/v1/workspaces/42/pages/7/history" { + if gotPath != "/rest/api/v2/workspaces/42/pages/7/history" { t.Errorf("path: got %s", gotPath) } if len(revs) != 2 { @@ -272,7 +273,7 @@ func TestClient_ErrorResponseMapping(t *testing.T) { { name: "JSON error → APIError", status: http.StatusForbidden, - body: `{"code":"INSUFFICIENT_PERMISSION","message":"missing pages:write"}`, + body: `{"error":{"code":"INSUFFICIENT_PERMISSION","message":"missing pages:write"}}`, wantAPIErr: true, wantContain: "missing pages:write", }, @@ -310,7 +311,7 @@ func TestClient_MovePage_SerializesSiblings(t *testing.T) { var gotBody PageMoveRequest c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { readJSONBody(t, r, &gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 42}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 42}}) }) parent := 7 @@ -349,7 +350,7 @@ func TestClient_MovePage_OmitsEmptySiblings(t *testing.T) { var rawBody []byte c, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { rawBody, _ = io.ReadAll(r.Body) - _ = json.NewEncoder(w).Encode(Page{ID: 42}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 42}}) }) parent := 7 if _, err := c.MovePage(99, 42, &parent, nil, nil); err != nil { diff --git a/internal/wscli/page_diagram_test.go b/internal/wscli/page_diagram_test.go index d46b5ae..3a40ee1 100644 --- a/internal/wscli/page_diagram_test.go +++ b/internal/wscli/page_diagram_test.go @@ -17,11 +17,11 @@ func TestClientPageDiagramOperations(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("method: want GET, got %s", r.Method) } - if r.URL.Path != "/rest/api/v1/workspaces/42/pages/7/diagrams" { + if r.URL.Path != "/rest/api/v2/workspaces/42/pages/7/diagrams" { t.Errorf("path: got %s", r.URL.Path) } _ = json.NewEncoder(w).Encode(map[string]any{ - "items": []PageDiagram{{PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid"}}, + "data": []PageDiagram{{PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid"}}, }) }) @@ -39,10 +39,10 @@ func TestClientPageDiagramOperations(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("method: want GET, got %s", r.Method) } - if r.URL.Path != "/rest/api/v1/workspaces/42/pages/7/diagrams/91" { + if r.URL.Path != "/rest/api/v2/workspaces/42/pages/7/diagrams/91" { t.Errorf("path: got %s", r.URL.Path) } - _ = json.NewEncoder(w).Encode(PageDiagram{PageID: 7, AttachmentID: 91, Name: "Flow"}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": PageDiagram{PageID: 7, AttachmentID: 91, Name: "Flow"}}) }) got, err := client.GetPageDiagram(42, 7, 91) @@ -60,12 +60,12 @@ func TestClientPageDiagramOperations(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("method: want POST, got %s", r.Method) } - if r.URL.Path != "/rest/api/v1/workspaces/42/pages/7/diagrams" { + if r.URL.Path != "/rest/api/v2/workspaces/42/pages/7/diagrams" { t.Errorf("path: got %s", r.URL.Path) } readJSONBody(t, r, &body) w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(PageDiagram{PageID: 7, AttachmentID: 92}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": PageDiagram{PageID: 7, AttachmentID: 92}}) }) hash := "content-hash-1" @@ -95,14 +95,14 @@ func TestClientPageDiagramOperations(t *testing.T) { t.Run("update sends replacement scene and expected hash", func(t *testing.T) { var body PageDiagramUpdateRequest client, _ := newTestPageClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Errorf("method: want PUT, got %s", r.Method) + if r.Method != http.MethodPatch { + t.Errorf("method: want PATCH, got %s", r.Method) } - if r.URL.Path != "/rest/api/v1/workspaces/42/pages/7/diagrams/91" { + if r.URL.Path != "/rest/api/v2/workspaces/42/pages/7/diagrams/91" { t.Errorf("path: got %s", r.URL.Path) } readJSONBody(t, r, &body) - _ = json.NewEncoder(w).Encode(PageDiagram{PageID: 7, AttachmentID: 93}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": PageDiagram{PageID: 7, AttachmentID: 93}}) }) hash := "content-hash-2" @@ -146,7 +146,7 @@ func TestPageDiagramCommandsCoverAllOperations(t *testing.T) { name: "create mermaid at explicit placement", args: []string{"page", "diagram", "create", "7", "--name", "Flow", "--mermaid", "graph TD; A-->B", "--placement", "start", "--expected-content-hash", "hash-1"}, method: http.MethodPost, - path: "/rest/api/v1/workspaces/42/pages/7/diagrams", + path: "/rest/api/v2/workspaces/42/pages/7/diagrams", status: http.StatusCreated, response: PageDiagram{ PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid", ContentHash: "hash-2", @@ -168,16 +168,16 @@ func TestPageDiagramCommandsCoverAllOperations(t *testing.T) { name: "list table output", args: []string{"page", "diagram", "list", "7", "-o", "table"}, method: http.MethodGet, - path: "/rest/api/v1/workspaces/42/pages/7/diagrams", + path: "/rest/api/v2/workspaces/42/pages/7/diagrams", status: http.StatusOK, - response: map[string]any{"items": []PageDiagram{{PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid", ContentHash: "hash-2"}}}, + response: []PageDiagram{{PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid", ContentHash: "hash-2"}}, wantOutput: []string{"ATTACHMENT", "PAGE", "KIND", "CONTENT HASH", "91", "7", "mermaid", "hash-2"}, }, { name: "get", args: []string{"page", "diagram", "get", "7", "91"}, method: http.MethodGet, - path: "/rest/api/v1/workspaces/42/pages/7/diagrams/91", + path: "/rest/api/v2/workspaces/42/pages/7/diagrams/91", status: http.StatusOK, response: PageDiagram{PageID: 7, AttachmentID: 91, Name: "Flow", Kind: "mermaid", ContentHash: "hash-2"}, wantOutput: []string{`"attachment_id": 91`, `"name": "Flow"`}, @@ -185,8 +185,8 @@ func TestPageDiagramCommandsCoverAllOperations(t *testing.T) { { name: "update inline Excalidraw", args: []string{"page", "diagram", "update", "7", "91", "--excalidraw", scene, "--expected-content-hash", "hash-2"}, - method: http.MethodPut, - path: "/rest/api/v1/workspaces/42/pages/7/diagrams/91", + method: http.MethodPatch, + path: "/rest/api/v2/workspaces/42/pages/7/diagrams/91", status: http.StatusOK, response: PageDiagram{ PageID: 7, AttachmentID: 92, Name: "Flow", Kind: "excalidraw", ContentHash: "hash-3", @@ -224,7 +224,7 @@ func TestPageDiagramCommandsCoverAllOperations(t *testing.T) { tc.assertBody(t, body) } w.WriteHeader(tc.status) - _ = json.NewEncoder(w).Encode(tc.response) + _ = json.NewEncoder(w).Encode(map[string]any{"data": tc.response}) })) t.Cleanup(server.Close) @@ -260,7 +260,7 @@ func TestPageDiagramCommandFromFile(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { readJSONBody(t, r, &gotBody) w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(PageDiagram{PageID: 7, AttachmentID: 91}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": PageDiagram{PageID: 7, AttachmentID: 91}}) })) t.Cleanup(server.Close) @@ -281,12 +281,12 @@ func TestPageDiagramErrorsAreActionable(t *testing.T) { err error want string }{ - {name: "invalid scene", err: &APIError{Status: 400, Code: "VALIDATION_FAILED", ErrorMessage: "invalid Excalidraw scene"}, want: "invalid diagram payload"}, - {name: "payload too large", err: &APIError{Status: 413, Code: "VALIDATION_FAILED", ErrorMessage: "diagram payload is too large"}, want: "invalid diagram payload"}, - {name: "stale page", err: &APIError{Status: 409, Code: "VALIDATION_FAILED", ErrorMessage: "page content changed"}, want: "stale Page content"}, - {name: "missing diagram", err: &APIError{Status: 404, Code: "NOT_FOUND", ErrorMessage: "not found"}, want: "diagram attachment 91 on Page 7 was not found"}, - {name: "missing page access", err: &APIError{Status: 404, Code: "NOT_FOUND", ErrorMessage: "not found"}, want: "Page 7 was not found, or you lack page access"}, - {name: "scope denied", err: &APIError{Status: 403, Code: "INSUFFICIENT_SCOPE", ErrorMessage: "missing pages:write"}, want: "token lacks the required pages API scope"}, + {name: "invalid scene", err: &APIError{Status: 400, Code: "VALIDATION_FAILED", Message: "invalid Excalidraw scene"}, want: "invalid diagram payload"}, + {name: "payload too large", err: &APIError{Status: 413, Code: "VALIDATION_FAILED", Message: "diagram payload is too large"}, want: "invalid diagram payload"}, + {name: "stale page", err: &APIError{Status: 409, Code: "VALIDATION_FAILED", Message: "page content changed"}, want: "stale Page content"}, + {name: "missing diagram", err: &APIError{Status: 404, Code: "NOT_FOUND", Message: "not found"}, want: "diagram attachment 91 on Page 7 was not found"}, + {name: "missing page access", err: &APIError{Status: 404, Code: "NOT_FOUND", Message: "not found"}, want: "Page 7 was not found, or you lack page access"}, + {name: "scope denied", err: &APIError{Status: 403, Code: "INSUFFICIENT_SCOPE", Message: "missing pages:write"}, want: "token lacks the required pages API scope"}, } for _, tc := range tests { diff --git a/internal/wscli/page_test.go b/internal/wscli/page_test.go index 54f987f..df75ccd 100644 --- a/internal/wscli/page_test.go +++ b/internal/wscli/page_test.go @@ -104,14 +104,14 @@ func TestResolvePageInput_H1RegexSkipsLowerHeadings(t *testing.T) { // be able to intentionally blank a page body. Before the fix, the command // detected "was --content set?" by checking pageEditContent != "", so an // explicit empty string short-circuited as "no content supplied" and the -// PUT body never carried Content=&"". +// PATCH body never carried Content=&"". func TestPageEditCommand_EmptyContentClearsBody(t *testing.T) { var gotBody PageUpdateRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/rest/api/v1/workspaces/42/pages/7"): + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/rest/api/v2/workspaces/42/pages/7"): _ = json.NewDecoder(r.Body).Decode(&gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7, Title: "Onboarding"}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7, Title: "Onboarding"}}) default: http.NotFound(w, r) } @@ -129,10 +129,10 @@ func TestPageEditCommand_EmptyContentClearsBody(t *testing.T) { t.Fatalf("Run exited with code %d; stderr=%s", code, errBuf.String()) } if gotBody.Content == nil { - t.Fatalf("PUT body did not include Content; got %+v", gotBody) + t.Fatalf("PATCH body did not include Content; got %+v", gotBody) } if *gotBody.Content != "" { - t.Errorf("PUT body Content: want \"\", got %q", *gotBody.Content) + t.Errorf("PATCH body Content: want \"\", got %q", *gotBody.Content) } } @@ -140,11 +140,11 @@ func TestPageMoveCommand_ResolvesDestinationWorkspace(t *testing.T) { var gotBody PageMoveRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/rest/api/v1/workspaces": + case r.Method == http.MethodGet && r.URL.Path == "/rest/api/v2/workspaces": _ = json.NewEncoder(w).Encode(PaginatedResponse[Workspace]{Data: []Workspace{{ID: 77, Key: "DEST"}}}) - case r.Method == http.MethodPost && r.URL.Path == "/rest/api/v1/workspaces/42/pages/7/move": + case r.Method == http.MethodPost && r.URL.Path == "/rest/api/v2/workspaces/42/pages/7/move": _ = json.NewDecoder(r.Body).Decode(&gotBody) - _ = json.NewEncoder(w).Encode(Page{ID: 7, WorkspaceID: 77, Path: "/7/"}) + _ = json.NewEncoder(w).Encode(map[string]any{"data": Page{ID: 7, WorkspaceID: 77, Path: "/7/"}}) default: http.NotFound(w, r) } diff --git a/internal/wscli/status_test.go b/internal/wscli/status_test.go index 14ab665..b2d8e9f 100644 --- a/internal/wscli/status_test.go +++ b/internal/wscli/status_test.go @@ -28,18 +28,18 @@ func TestStatusListScopesTwoWorkspaces(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { - case "/rest/api/v1/workspaces": + case "/rest/api/v2/workspaces": _ = json.NewEncoder(w).Encode(PaginatedResponse[Workspace]{Data: workspaces}) - case "/rest/api/v1/workspaces/11": - _ = json.NewEncoder(w).Encode(workspaces[0]) - case "/rest/api/v1/workspaces/22": - _ = json.NewEncoder(w).Encode(workspaces[1]) - case "/rest/api/v1/workspaces/11/statuses": - _ = json.NewEncoder(w).Encode(workspaceStatuses[11]) - case "/rest/api/v1/workspaces/22/statuses": - _ = json.NewEncoder(w).Encode(workspaceStatuses[22]) - case "/rest/api/v1/statuses": - _ = json.NewEncoder(w).Encode(systemStatuses) + case "/rest/api/v2/workspaces/11": + _ = json.NewEncoder(w).Encode(map[string]any{"data": workspaces[0]}) + case "/rest/api/v2/workspaces/22": + _ = json.NewEncoder(w).Encode(map[string]any{"data": workspaces[1]}) + case "/rest/api/v2/workspaces/11/statuses": + _ = json.NewEncoder(w).Encode(map[string]any{"data": workspaceStatuses[11]}) + case "/rest/api/v2/workspaces/22/statuses": + _ = json.NewEncoder(w).Encode(map[string]any{"data": workspaceStatuses[22]}) + case "/rest/api/v2/statuses": + _ = json.NewEncoder(w).Encode(map[string]any{"data": systemStatuses}) default: http.NotFound(w, r) } diff --git a/internal/wscli/task_usability_test.go b/internal/wscli/task_usability_test.go new file mode 100644 index 0000000..5350cb4 --- /dev/null +++ b/internal/wscli/task_usability_test.go @@ -0,0 +1,280 @@ +package wscli_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "windshift/internal/wscli" +) + +func runTaskUsability(t *testing.T, server *httptest.Server, args ...string) (int, string, string) { + t.Helper() + config := filepath.Join(t.TempDir(), "ws.toml") + if err := os.WriteFile(config, []byte(""), 0600); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + argv := append([]string{"--config", config}, args...) + code := wscli.Run(context.Background(), argv, nil, &out, &errOut, map[string]string{"WS_URL": server.URL, "WS_TOKEN": "synthetic-test", "WS_WORKSPACE": ""}) + return code, out.String(), errOut.String() +} + +func TestTaskListAllFollowsPagesAndPreservesFilters(t *testing.T) { + var pages []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/api/v2/items" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if r.URL.Query().Get("milestone_id") != "7" || r.URL.Query().Get("page_size") != "2" { + t.Errorf("filters: %s", r.URL.RawQuery) + } + page := r.URL.Query().Get("page") + pages = append(pages, page) + if page == "1" { + fmt.Fprint(w, `{"data":[{"id":1},{"id":2}],"pagination":{"page":1,"page_size":2,"total_items":3,"total_pages":2}}`) + return + } + fmt.Fprint(w, `{"data":[{"id":3}],"pagination":{"page":2,"page_size":2,"total_items":3,"total_pages":2}}`) + })) + defer server.Close() + code, out, errOut := runTaskUsability(t, server, "task", "ls", "--all", "--limit", "2", "--milestone", "7") + if code != 0 { + t.Fatalf("run: %s", errOut) + } + var result wscli.PaginatedResponse[wscli.Item] + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatal(err) + } + if len(result.Data) != 3 || result.Data[2].ID != 3 || strings.Join(pages, ",") != "1,2" { + t.Fatalf("result=%s pages=%v", out, pages) + } +} + +func TestTaskListPaginationWarningDoesNotPolluteCSV(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"id":1,"title":"First"}],"pagination":{"page":1,"page_size":1,"total_items":3,"total_pages":3}}`) + })) + defer server.Close() + code, out, errOut := runTaskUsability(t, server, "task", "ls", "--limit", "1", "-o", "csv") + if code != 0 || !strings.Contains(errOut, "1 of 3") || strings.Contains(out, "Showing") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } +} + +func TestTaskListAllValidatesFinalUniqueItemCount(t *testing.T) { + for _, tc := range []struct { + name string + lastIDs []int + total int + wantSuccess bool + }{ + {"complete", []int{3}, 3, true}, + {"empty final page", nil, 3, false}, + {"duplicate final page", []int{2}, 3, false}, + {"partial final page", []int{3}, 4, false}, + {"more than advertised", []int{3}, 2, false}, + {"overlap but complete", []int{2, 3}, 3, true}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 2 { + http.Error(w, "unexpected page", 500) + return + } + ids := []int{1, 2} + if requests == 2 { + ids = tc.lastIDs + } + items := make([]wscli.Item, 0, len(ids)) + for _, id := range ids { + items = append(items, wscli.Item{ID: id}) + } + if err := json.NewEncoder(w).Encode(wscli.PaginatedResponse[wscli.Item]{Data: items, Pagination: wscli.PaginationMeta{Page: requests, PageSize: 2, TotalItems: tc.total, TotalPages: 2}}); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + code, out, errOut := runTaskUsability(t, server, "task", "ls", "--all", "--limit", "2") + if requests != 2 || (code == 0) != tc.wantSuccess { + t.Fatalf("requests=%d code=%d stdout=%q stderr=%q", requests, code, out, errOut) + } + if !tc.wantSuccess && (out != "" || !strings.Contains(errOut, "incomplete pagination")) { + t.Fatalf("stdout=%q stderr=%q", out, errOut) + } + if tc.wantSuccess { + var result wscli.PaginatedResponse[wscli.Item] + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatal(err) + } + if len(result.Data) != 3 || result.Data[0].ID != 1 || result.Data[1].ID != 2 || result.Data[2].ID != 3 || result.Pagination.TotalItems != 3 { + t.Fatalf("result=%s", out) + } + } + }) + } +} + +func TestTaskListRejectsInvalidPaginationBeforeFetchingItems(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL) + http.NotFound(w, r) + })) + defer server.Close() + for _, args := range [][]string{{"--limit", "101"}, {"--page", "0"}, {"--all", "--page", "2"}} { + code, _, _ := runTaskUsability(t, server, append([]string{"task", "ls"}, args...)...) + if code == 0 { + t.Errorf("accepted %v", args) + } + } +} + +func TestTaskStatusNameResolutionAndUnknownStatus(t *testing.T) { + itemsCalled := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/v2/workspaces/42/statuses": + fmt.Fprint(w, `{"data":[{"id":3,"name":"Erledigt"},{"id":4,"name":"Bereit"}]}`) + case "/rest/api/v2/items": + itemsCalled++ + if r.URL.Query().Get("status_id_not") != "3" { + t.Errorf("status query=%s", r.URL.RawQuery) + } + fmt.Fprint(w, `{"data":[],"pagination":{"page":1,"page_size":50,"total_items":0,"total_pages":0}}`) + default: + t.Errorf("unexpected path %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + code, _, errOut := runTaskUsability(t, server, "task", "ls", "-w", "42", "-s", "~erledigt") + if code != 0 { + t.Fatal(errOut) + } + code, _, errOut = runTaskUsability(t, server, "task", "ls", "-w", "42", "-s", "Quatsch") + if code == 0 || !strings.Contains(errOut, "Erledigt") || itemsCalled != 1 { + t.Fatalf("code=%d error=%s item requests=%d", code, errOut, itemsCalled) + } +} + +func TestTaskEditResolvesParentKeyAndSendsNullToClearIt(t *testing.T) { + var parents []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/rest/api/v2/workspaces/CTX/items/9": + fmt.Fprint(w, `{"data":{"id":99,"workspace_id":42}}`) + case r.Method == "PATCH" && r.URL.Path == "/rest/api/v2/items/42": + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + parents = append(parents, string(body["parent_id"])) + fmt.Fprint(w, `{"data":{"id":42,"workspace_id":42}}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + for _, parent := range []string{"CTX-9", "0"} { + code, _, errOut := runTaskUsability(t, server, "task", "edit", "42", "--parent", parent) + if code != 0 { + t.Fatal(errOut) + } + } + if fmt.Sprint(parents) != "[99 null]" { + t.Fatalf("parents=%v", parents) + } +} + +func TestTaskListAllHandlesEmptyAndRejectsBrokenPagination(t *testing.T) { + for _, tc := range []struct { + name, response string + wantSuccess bool + }{ + {"empty", `{"data":[],"pagination":{"page":1,"page_size":50,"total_items":0,"total_pages":0}}`, true}, + {"missing metadata", `{"data":[{"id":1}]}`, false}, + {"stuck on first page", `{"data":[{"id":1}],"pagination":{"page":1,"page_size":1,"total_items":2,"total_pages":2}}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 2 { + t.Error("pagination did not stop") + http.Error(w, "loop", 500) + return + } + fmt.Fprint(w, tc.response) + })) + defer server.Close() + code, out, errOut := runTaskUsability(t, server, "task", "ls", "--all") + if (code == 0) != tc.wantSuccess { + t.Fatalf("code=%d out=%s err=%s", code, out, errOut) + } + if tc.wantSuccess && !strings.Contains(out, `"data": []`) { + t.Fatalf("empty result=%s", out) + } + }) + } +} + +func TestTaskListRejectsWrongSinglePage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "3" { + t.Errorf("page=%s", r.URL.RawQuery) + } + fmt.Fprint(w, `{"data":[{"id":1}],"pagination":{"page":1,"page_size":1,"total_items":3,"total_pages":3}}`) + })) + t.Cleanup(server.Close) + code, out, errOut := runTaskUsability(t, server, "task", "ls", "--page", "3", "--limit", "1") + if code == 0 || out != "" || !strings.Contains(errOut, "invalid pagination") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } +} + +func TestTaskMilestoneNameSearchesAllPagesWithoutFuzzyMatch(t *testing.T) { + var milestonePages []string + itemsCalled := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/v2/workspaces/42/milestones": + page := r.URL.Query().Get("page") + milestonePages = append(milestonePages, page) + if page == "1" { + fmt.Fprint(w, `{"data":[{"id":6,"name":"Q4 planning"}],"pagination":{"page":1,"page_size":100,"total_items":101,"total_pages":2}}`) + } else { + fmt.Fprint(w, `{"data":[{"id":7,"name":"Q4"}],"pagination":{"page":2,"page_size":100,"total_items":101,"total_pages":2}}`) + } + case "/rest/api/v2/items": + itemsCalled++ + if r.URL.Query().Get("milestone_id") != "7" { + t.Errorf("filter=%s", r.URL.RawQuery) + } + fmt.Fprint(w, `{"data":[],"pagination":{"page":1,"total_pages":0}}`) + default: + t.Errorf("unexpected request %s", r.URL) + http.NotFound(w, r) + } + })) + defer server.Close() + code, _, errOut := runTaskUsability(t, server, "task", "ls", "-w", "42", "--milestone", "q4") + if code != 0 || strings.Join(milestonePages, ",") != "1,2" { + t.Fatalf("code=%d err=%s pages=%v", code, errOut, milestonePages) + } + code, _, errOut = runTaskUsability(t, server, "task", "ls", "-w", "42", "--milestone", "planning") + if code == 0 || !strings.Contains(errOut, "not found") || itemsCalled != 1 { + t.Fatalf("code=%d err=%s requests=%d", code, errOut, itemsCalled) + } +} diff --git a/internal/wscli/windshift_docs_test.go b/internal/wscli/windshift_docs_test.go index 6418789..17c9b62 100644 --- a/internal/wscli/windshift_docs_test.go +++ b/internal/wscli/windshift_docs_test.go @@ -87,7 +87,7 @@ func TestGenerateWindshiftMDCorrectness(t *testing.T) { func TestWriteWindshiftMDFailsWhenTransitionsAreUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/rest/api/v1/workflows/7/transitions" { + if r.URL.Path != "/rest/api/v2/workflows/7/transitions" { t.Errorf("unexpected request path: %s", r.URL.Path) } http.Error(w, "transitions unavailable", http.StatusServiceUnavailable) diff --git a/tests/fixture_identity_test.go b/tests/fixture_identity_test.go new file mode 100644 index 0000000..c124567 --- /dev/null +++ b/tests/fixture_identity_test.go @@ -0,0 +1,42 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +func TestFixtureAdminIdentity(t *testing.T) { + ts, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, ts) + want := DecodeV2Document[UserFx](t, MakeV2BearerRequest(t, ts, http.MethodGet, "/users/me", nil), http.StatusOK) + got := lookupAdminUser(t, ts) + if got.ID <= 0 || got.Username != "admin" || got != want { + t.Fatalf("admin fixture = %+v, authenticated bearer user = %+v", got, want) + } +} + +func TestFixtureWorkspaceLockdownRemovesImplicitAccess(t *testing.T) { + ts, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, ts) + workspace := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, ts, http.MethodPost, "/workspaces", map[string]any{ + "name": "Lockdown fixture", "key": "LOCKDOWN", + }), http.StatusCreated) + userID, username, password := CreateTestUserWithCredentials(t, ts, "lockdown_outsider", "lockdown@example.test") + outsider := *ts + outsider.SessionCookie = "" + outsider.BearerToken = createTokenWithScopesAsUser(t, ts, username, password, []string{"workspaces:read"}) + path := fmt.Sprintf("/workspaces/%d", workspace.ID) + if got := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, &outsider, http.MethodGet, path, nil), http.StatusOK); got.ID != workspace.ID { + t.Fatalf("open workspace = %+v", got) + } + LockDownWorkspace(t, ts, workspace.ID) + denied := MakeV2BearerRequest(t, &outsider, http.MethodGet, path, nil) + AssertStatusCode(t, denied, http.StatusNotFound) + denied.Body.Close() + // An explicit grant restores access on the same route with the same token. + AssignWorkspaceRole(t, ts, userID, workspace.ID, "Viewer") + if got := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, &outsider, http.MethodGet, path, nil), http.StatusOK); got.ID != workspace.ID { + t.Fatalf("explicitly granted workspace = %+v", got) + } +} diff --git a/tests/fixture_pagination_test.go b/tests/fixture_pagination_test.go new file mode 100644 index 0000000..9d6566b --- /dev/null +++ b/tests/fixture_pagination_test.go @@ -0,0 +1,47 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +func TestFixtureItemsByWorkspaceIncludesEveryPage(t *testing.T) { + ts, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, ts) + workspaceID, _ := CreateTestWorkspace(t, ts, "Paginated fixtures", "FIXPAGE") + otherID, _ := CreateTestWorkspace(t, ts, "Excluded fixtures", "FIXOTHER") + create := func(workspaceID, index int) int { + return DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, ts, http.MethodPost, "/items", map[string]any{ + "workspace_id": workspaceID, "title": fmt.Sprintf("Fixture item %03d", index), + }), http.StatusCreated).ID + } + excluded := create(otherID, 0) + if items := GetItemsByWorkspace(t, ts, workspaceID); len(items) != 0 { + t.Fatalf("empty workspace returned %d items", len(items)) + } + // The helper requests 100 items per page. One additional item must neither + // disappear nor duplicate an item from the first page. + want := make(map[int]bool, fixtureItemPageSize+1) + for index := 0; index < fixtureItemPageSize+1; index++ { + id := create(workspaceID, index) + if id <= 0 || want[id] { + t.Fatalf("invalid or duplicate created item ID: %d", id) + } + want[id] = true + } + items := GetItemsByWorkspace(t, ts, workspaceID) + if len(items) != len(want) { + t.Fatalf("all pages returned %d items, want %d", len(items), len(want)) + } + for _, item := range items { + id := ExtractIDFromResponse(t, item) + if id == excluded || !want[id] || item["workspace_id"] != float64(workspaceID) { + t.Fatalf("unexpected, duplicate or foreign item: %+v", item) + } + delete(want, id) + } + if len(want) != 0 { + t.Fatalf("missing item IDs: %v", want) + } +} diff --git a/tests/fixtures.go b/tests/fixtures.go index ca8bc55..619b96f 100644 --- a/tests/fixtures.go +++ b/tests/fixtures.go @@ -3,8 +3,8 @@ package tests import ( "encoding/json" "fmt" - "io" "net/http" + "slices" "sort" "testing" "time" @@ -99,9 +99,9 @@ type ItemFx struct { } // SeedWorld creates the fixture world against testServer. It mints two -// workspaces, statuses (5 per workspace), milestones, iterations, labels, -// users, and ~14 items spread across the dimensions. Authentication uses the -// admin bearer token CreateBearerToken put on the testServer. +// workspaces, their three default statuses, milestones, iterations, labels, +// users, and 13 items spread across the dimensions. Domain fixtures use v2; +// account bootstrap and authentication use their retained session routes. func SeedWorld(t *testing.T, ts *TestServer) *World { t.Helper() w := &World{} @@ -116,9 +116,19 @@ func SeedWorld(t *testing.T, ts *TestServer) *World { // Use a regular type explicitly. Map iteration is nondeterministic and can // otherwise select the generic Sub-task type, which requires a parent. - configSetID := GetDefaultConfigurationSet(t, ts) - itemTypes := GetItemTypes(t, ts, configSetID) - w.defaultType = RequireItemTypeID(t, itemTypes, "Task") + itemTypes := DecodeV2Document[[]struct { + ID int `json:"id"` + Name string `json:"name"` + }](t, MakeV2SessionRequest(t, ts, http.MethodGet, fmt.Sprintf("/workspaces/%d/item-types", w.Alpha.ID), nil), http.StatusOK) + for _, itemType := range itemTypes { + if itemType.Name == "Task" { + w.defaultType = itemType.ID + break + } + } + if w.defaultType == 0 { + t.Fatal("Alpha workspace is missing the regular Task item type") + } // 3. Milestones (in Alpha) ---------------------------------------------- w.Milestones.Q1 = createMilestoneFx(t, ts, w.Alpha.ID, "Q1 Plan", "in-progress") @@ -187,8 +197,9 @@ func SeedWorld(t *testing.T, ts *TestServer) *World { {"beta only item two", w.Beta.ID, 0, "", 0, 0, aliceID, nil}, } + workspaceKeys := map[int]string{w.Alpha.ID: w.Alpha.Key, w.Beta.ID: w.Beta.Key} for _, s := range seeds { - fx := createItemFx(t, ts, s.WorkspaceID, s.Title, s.StatusID, s.MilestoneID, s.IterationID, s.AssigneeID, w.defaultType) + fx := createItemFx(t, ts, s.WorkspaceID, workspaceKeys[s.WorkspaceID], s.Title, s.StatusID, s.MilestoneID, s.IterationID, s.AssigneeID, w.defaultType) fx.StatusName = s.StatusName if len(s.LabelIDs) > 0 { setItemLabels(t, ts, fx.ID, s.LabelIDs) @@ -314,18 +325,10 @@ func createMilestoneFx(t *testing.T, ts *TestServer, workspaceID int, name, stat "description": "fixture milestone", "status": status, } - resp := MakeBearerRequest(t, ts, http.MethodPost, - fmt.Sprintf("/rest/api/v1/workspaces/%d/milestones", workspaceID), body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("create milestone %q: %d - %s", name, resp.StatusCode, string(raw)) - } - var out struct { + out := DecodeV2Document[struct { ID int `json:"id"` Name string `json:"name"` - } - DecodeJSON(t, resp, &out) + }](t, MakeV2BearerRequest(t, ts, http.MethodPost, fmt.Sprintf("/workspaces/%d/milestones", workspaceID), body), http.StatusCreated) return MilestoneFx{ID: out.ID, Name: out.Name} } @@ -342,44 +345,28 @@ func createIterationFx(t *testing.T, ts *TestServer, workspaceID int, name, stat "start_date": now.AddDate(0, 0, -7).Format("2006-01-02"), "end_date": now.AddDate(0, 0, 7).Format("2006-01-02"), } - resp := MakeBearerRequest(t, ts, http.MethodPost, - fmt.Sprintf("/rest/api/v1/workspaces/%d/iterations", workspaceID), body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("create iteration %q: %d - %s", name, resp.StatusCode, string(raw)) - } - var out struct { + out := DecodeV2Document[struct { ID int `json:"id"` Name string `json:"name"` Status string `json:"status"` - } - DecodeJSON(t, resp, &out) + }](t, MakeV2BearerRequest(t, ts, http.MethodPost, fmt.Sprintf("/workspaces/%d/iterations", workspaceID), body), http.StatusCreated) return IterationFx{ID: out.ID, Name: out.Name, Status: out.Status} } func createLabelFx(t *testing.T, ts *TestServer, workspaceID int, name, color string) LabelFx { t.Helper() body := map[string]interface{}{ - "name": name, - "color": color, - "workspace_id": workspaceID, - } - resp := MakeAuthRequest(t, ts, http.MethodPost, "/labels", body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("create label %q: %d - %s", name, resp.StatusCode, string(raw)) + "name": name, + "color": color, } - var out struct { + out := DecodeV2Document[struct { ID int `json:"id"` Name string `json:"name"` - } - DecodeJSON(t, resp, &out) + }](t, MakeV2SessionRequest(t, ts, http.MethodPost, fmt.Sprintf("/workspaces/%d/labels", workspaceID), body), http.StatusCreated) return LabelFx{ID: out.ID, Name: out.Name} } -func createItemFx(t *testing.T, ts *TestServer, workspaceID int, title string, statusID, milestoneID, iterationID, assigneeID, itemTypeID int) ItemFx { +func createItemFx(t *testing.T, ts *TestServer, workspaceID int, workspaceKey, title string, statusID, milestoneID, iterationID, assigneeID, itemTypeID int) ItemFx { t.Helper() body := map[string]interface{}{ "title": title, @@ -401,37 +388,23 @@ func createItemFx(t *testing.T, ts *TestServer, workspaceID int, title string, s if assigneeID > 0 { body["assignee_id"] = assigneeID } - resp := MakeAuthRequest(t, ts, http.MethodPost, "/items", body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("create item %q: %d - %s", title, resp.StatusCode, string(raw)) - } - var out struct { + out := DecodeV2Document[struct { ID int `json:"id"` WorkspaceItemNumber int `json:"workspace_item_number"` StatusID *int `json:"status_id"` + }](t, MakeV2SessionRequest(t, ts, http.MethodPost, "/items", body), http.StatusCreated) + if out.StatusID == nil || *out.StatusID <= 0 || (statusID > 0 && *out.StatusID != statusID) { + t.Fatalf("created item status = %v, expected assigned status (requested %d)", out.StatusID, statusID) } - DecodeJSON(t, resp, &out) - statusFinal := 0 - if out.StatusID != nil { - statusFinal = *out.StatusID - } else if statusID > 0 { - statusFinal = statusID - } - wsKey := "" - for _, candidate := range listWorkspaceKeys(t, ts) { - if candidate.ID == workspaceID { - wsKey = candidate.Key - break - } + if out.ID <= 0 || out.WorkspaceItemNumber <= 0 || workspaceKey == "" { + t.Fatalf("invalid created item or workspace key: item=%+v key=%q", out, workspaceKey) } return ItemFx{ ID: out.ID, - Key: fmt.Sprintf("%s-%d", wsKey, out.WorkspaceItemNumber), + Key: fmt.Sprintf("%s-%d", workspaceKey, out.WorkspaceItemNumber), WorkspaceID: workspaceID, Title: title, - StatusID: statusFinal, + StatusID: *out.StatusID, MilestoneID: milestoneID, IterationID: iterationID, AssigneeID: assigneeID, @@ -441,61 +414,27 @@ func createItemFx(t *testing.T, ts *TestServer, workspaceID int, title string, s func setItemLabels(t *testing.T, ts *TestServer, itemID int, labelIDs []int) { t.Helper() body := map[string]interface{}{"label_ids": labelIDs} - resp := MakeAuthRequest(t, ts, http.MethodPut, fmt.Sprintf("/items/%d/labels", itemID), body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("set item %d labels: %d - %s", itemID, resp.StatusCode, string(raw)) - } -} - -type wsKeyPair struct { - ID int - Key string -} - -func listWorkspaceKeys(t *testing.T, ts *TestServer) []wsKeyPair { - t.Helper() - resp := MakeAuthRequest(t, ts, http.MethodGet, "/workspaces", nil) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("list workspaces: %d", resp.StatusCode) - } - bodyBytes, _ := io.ReadAll(resp.Body) - var arr []map[string]interface{} - if err := json.Unmarshal(bodyBytes, &arr); err != nil { - // Workspaces endpoint may return paginated struct. Fall back to that. - var paged struct { - Workspaces []map[string]interface{} `json:"workspaces"` - } - if jerr := json.Unmarshal(bodyBytes, &paged); jerr != nil { - t.Fatalf("decode workspaces: %v", err) - } - arr = paged.Workspaces + labels := DecodeV2Document[[]struct { + ID int `json:"id"` + }](t, MakeV2SessionRequest(t, ts, http.MethodPut, fmt.Sprintf("/items/%d/labels", itemID), body), http.StatusOK) + got := make([]int, 0, len(labels)) + for _, label := range labels { + got = append(got, label.ID) } - out := make([]wsKeyPair, 0, len(arr)) - for _, m := range arr { - idF, _ := m["id"].(float64) - k, _ := m["key"].(string) - out = append(out, wsKeyPair{ID: int(idF), Key: k}) + want := append([]int(nil), labelIDs...) + sort.Ints(got) + sort.Ints(want) + if !slices.Equal(got, want) { + t.Fatalf("item %d labels = %v, want %v", itemID, got, want) } - return out } func lookupDefaultStatuses(t *testing.T, ts *TestServer, workspaceID int) StatusSetFx { t.Helper() - resp := MakeBearerRequest(t, ts, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/statuses", workspaceID), nil) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("list workspace statuses: %d - %s", resp.StatusCode, string(raw)) - } - var arr []struct { + arr := DecodeV2Document[[]struct { ID int `json:"id"` Name string `json:"name"` - } - DecodeJSON(t, resp, &arr) + }](t, MakeV2BearerRequest(t, ts, http.MethodGet, fmt.Sprintf("/workspaces/%d/statuses", workspaceID), nil), http.StatusOK) out := StatusSetFx{} for _, s := range arr { switch s.Name { @@ -515,26 +454,21 @@ func lookupDefaultStatuses(t *testing.T, ts *TestServer, workspaceID int) Status func lookupAdminUser(t *testing.T, ts *TestServer) UserFx { t.Helper() - resp := MakeAuthRequest(t, ts, http.MethodGet, "/users", nil) + // Setup supplies an admin session. Resolve that exact identity rather than + // relying on a user-list route, ordering, or a hard-coded database ID. + resp := MakeAuthRequest(t, ts, http.MethodGet, "/auth/me", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Fatalf("list users: %d", resp.StatusCode) - } - var users []map[string]interface{} - DecodeJSON(t, resp, &users) - for _, u := range users { - if u["username"] == "admin" { - idF, _ := u["id"].(float64) - return UserFx{ID: int(idF), Username: "admin"} - } + t.Fatalf("resolve admin session: %d", resp.StatusCode) + } + var payload struct { + User UserFx `json:"user"` } - if len(users) > 0 { - idF, _ := users[0]["id"].(float64) - uname, _ := users[0]["username"].(string) - return UserFx{ID: int(idF), Username: uname} + DecodeJSON(t, resp, &payload) + if payload.User.ID <= 0 || payload.User.Username != "admin" { + t.Fatalf("expected setup admin identity, got %+v", payload.User) } - t.Fatal("no users found") - return UserFx{} + return payload.User } // idsFromJSONListItems extracts the .id field from every entry in a list diff --git a/tests/helpers.go b/tests/helpers.go index e9acd38..e153212 100644 --- a/tests/helpers.go +++ b/tests/helpers.go @@ -595,13 +595,7 @@ func CreateTestWorkspace(t *testing.T, testServer *TestServer, name, key string) key = shortKey("TEST") } - // Stays on /api/workspaces (cookie-auth) for now — the /api handler runs - // extra setup (default config-set association, default statuses, etc.) - // that the v1 POST /rest/api/v1/workspaces does not. Migrating this helper - // to v1 would require either (a) adding the same setup to v1's create - // path or (b) doing the auxiliary setup explicitly in the test helper. - // Both are deferred — the test suite is fully functional via cookie-auth - // and v1 has dedicated coverage in api_token_scope_test.go. + // V2 uses the production workspace service, including default configuration. workspaceData := map[string]interface{}{ "name": name, "key": key, @@ -609,18 +603,14 @@ func CreateTestWorkspace(t *testing.T, testServer *TestServer, name, key string) "active": true, } - resp := MakeAuthRequest(t, testServer, http.MethodPost, "/workspaces", workspaceData) - defer resp.Body.Close() - - AssertStatusCode(t, resp, http.StatusCreated) - - var result map[string]interface{} - DecodeJSON(t, resp, &result) - - workspaceID = ExtractIDFromResponse(t, result) - workspaceKey, _ = result["key"].(string) - - return workspaceID, workspaceKey + result := DecodeV2Document[struct { + ID int `json:"id"` + Key string `json:"key"` + }](t, MakeV2SessionRequest(t, testServer, http.MethodPost, "/workspaces", workspaceData), http.StatusCreated) + if result.ID <= 0 || result.Key == "" { + t.Fatalf("created workspace = %+v, expected a key and positive ID", result) + } + return result.ID, result.Key } // CreateTestCustomField creates a custom field and returns its ID @@ -1198,36 +1188,12 @@ func RevokeGlobalPermission(t *testing.T, testServer *TestServer, userID, permis // LockDownWorkspace restricts a workspace so that only explicitly assigned // users have access. It does this by assigning the Viewer role to the admin -// user (the bearer-token holder), which triggers the "has explicit Viewer +// user resolved from the setup session, which triggers the "has explicit Viewer // assignments" condition and blocks implicit everyone access. func LockDownWorkspace(t *testing.T, testServer *TestServer, workspaceID int) { t.Helper() - // Get the admin user's ID via GET /users (bearer-token compatible) - resp := MakeAuthRequest(t, testServer, http.MethodGet, "/users", nil) - defer resp.Body.Close() - AssertStatusCode(t, resp, http.StatusOK) - - var users []map[string]interface{} - DecodeJSON(t, resp, &users) - if len(users) == 0 { - t.Fatal("No users found") - } - - // Find admin user (username "admin" from setup) - var adminID int - for _, u := range users { - if u["username"] == "admin" { - adminID = int(u["id"].(float64)) - break - } - } - if adminID == 0 { - // Fallback to first user - adminID = int(users[0]["id"].(float64)) - } - - AssignWorkspaceRole(t, testServer, adminID, workspaceID, "Viewer") + AssignWorkspaceRole(t, testServer, lookupAdminUser(t, testServer).ID, workspaceID, "Viewer") } // CreateTestItem creates a work item in a workspace and returns its ID. @@ -1443,27 +1409,20 @@ func TriggerEmailProcessing(t *testing.T, testServer *TestServer, channelID int) } } +const fixtureItemPageSize = 100 + // GetItemsByWorkspace returns items in a workspace func GetItemsByWorkspace(t *testing.T, testServer *TestServer, workspaceID int) []map[string]interface{} { t.Helper() - - endpoint := fmt.Sprintf("/items?workspace_id=%d", workspaceID) - resp := MakeAuthRequest(t, testServer, http.MethodGet, endpoint, nil) - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Failed to get items: %d - %s", resp.StatusCode, string(body)) - } - - var result struct { - Items []map[string]interface{} `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - t.Fatalf("Failed to parse items response: %v", err) + var result []map[string]interface{} + for page := 1; ; page++ { + endpoint := fmt.Sprintf("/items?workspace_id=%d&page=%d&page_size=%d", workspaceID, page, fixtureItemPageSize) + items, pagination := DecodeV2Page[map[string]interface{}](t, MakeV2SessionRequest(t, testServer, http.MethodGet, endpoint, nil)) + result = append(result, items...) + if page >= pagination.TotalPages { + return result + } } - - return result.Items } // AssociateWorkspaceWithConfigSet associates a workspace with a configuration set diff --git a/tests/matrix_classes.go b/tests/matrix_classes.go index f478b4a..5384235 100644 --- a/tests/matrix_classes.go +++ b/tests/matrix_classes.go @@ -3,21 +3,16 @@ package tests import "net/http" // PermissionClass is a named (actor → expected status) row that the matrix -// asserts for every route classified into it. Each class encodes both the +// asserts for its representative route. Each class encodes both the // allow side (which actors get 2xx) and the deny side with the *exact* code // the security policy mandates. AssertRejected-style permissive checks // (accept any 401/403/404) are intentionally not used here — drift from // 404→403 on workspace-permission denial is exactly the leak this matrix // catches. // -// Status-code policy (from MEMORY.md): -// -// workspace-permission denial → 404 (existence privacy) -// cross-workspace direct-ID → 404 -// per-record whitelist denial → 403 -// global permission denial → 403 -// token scope insufficient → 403 -// no session / no token → 401 +// Expectations describe the current v2 representative routes, not a universal +// status rule for every API. Scope denial is checked separately from these +// role cells: their tokens deliberately carry all available nonadmin scopes. type PermissionClass struct { Name string // e.g. "workspace.item.view" Description string // human-readable note (which permission, why) @@ -28,15 +23,13 @@ type PermissionClass struct { // list and runs every actor in MatrixActors against the representative // route for each class (see matrix_routes.go). // -// Seeded with one class — workspace.item.view — so the headline 404-not-403 -// invariant gets coverage day one. Expand one class per resource family per -// commit (see plan: items.edit/delete, comments, attachments, channels, -// workflow, custom fields, time, approvals, tests, assets, teams, then -// workspace.admin + global.system.admin). +// Four v2 classes currently run on both auth mounts. Destructive operations, +// record ownership, per-page ACL overrides and portal actors need their own +// fixtures; the representative matrix does not claim those policies. var Classes = []PermissionClass{ { Name: "workspace.item.view", - Description: "GET /items/{id} — requires models.PermissionItemView on the item's workspace.", + Description: "V2 GET /items/{id} requires PermissionItemView on the item's workspace.", Expected: map[MatrixActor]int{ ActorAnonymous: http.StatusUnauthorized, // 401 ActorNoMembership: http.StatusNotFound, // 404 — workspace-perm denial @@ -51,7 +44,7 @@ var Classes = []PermissionClass{ }, { Name: "workspace.item.edit", - Description: "PUT /items/{id} — requires models.PermissionItemEdit. Item handler returns 404 on permission denial (existence privacy).", + Description: "V2 PATCH /items/{id} requires PermissionItemEdit and masks permission denial with 404.", Expected: map[MatrixActor]int{ ActorAnonymous: http.StatusUnauthorized, // 401 ActorNoMembership: http.StatusNotFound, // 404 @@ -69,7 +62,7 @@ var Classes = []PermissionClass{ }, { Name: "workspace.item.comment", - Description: "POST /items/{id}/comments — requires models.PermissionItemComment. Comment handler returns 404 on denial (existence privacy).", + Description: "V2 POST /items/{id}/comments requires PermissionItemComment and masks denial with 404.", Expected: map[MatrixActor]int{ ActorAnonymous: http.StatusUnauthorized, // 401 — auth runs before body parse ActorNoMembership: http.StatusNotFound, // 404 @@ -88,20 +81,18 @@ var Classes = []PermissionClass{ }, { Name: "workspace.admin", - // Workspace handler diverges from the item-handler 404 policy: denial - // is 403 (respondForbidden), not 404. Encode current behavior so the - // matrix catches drift in either direction; the broader 403→404 - // alignment is a separate audit (see plan: out of scope). - Description: "PUT /workspaces/{id} — requires models.PermissionWorkspaceAdmin. Workspace handler returns 403 on denial (unlike item handler).", + // Current v2 WorkspaceApplicationService.Update returns ErrNotFound + // for denied CanAdminWorkspace, unlike the removed cookie PUT handler. + Description: "V2 PATCH /workspaces/{id} requires PermissionWorkspaceAdmin and masks denial with 404.", Expected: map[MatrixActor]int{ ActorAnonymous: http.StatusUnauthorized, // 401 - ActorNoMembership: http.StatusForbidden, // 403 — workspace handler uses respondForbidden - ActorCrossWorkspaceMember: http.StatusForbidden, // 403 - ActorWorkspaceViewer: http.StatusForbidden, // 403 - ActorWorkspaceEditor: http.StatusForbidden, // 403 — Editor lacks workspace.admin - ActorWorkspaceAdmin: http.StatusOK, // 200 — Administrator role has workspace.admin - ActorWorkspaceTester: http.StatusForbidden, // 403 - ActorSystemAdmin: http.StatusOK, // 200 + ActorNoMembership: http.StatusNotFound, + ActorCrossWorkspaceMember: http.StatusNotFound, + ActorWorkspaceViewer: http.StatusNotFound, + ActorWorkspaceEditor: http.StatusNotFound, + ActorWorkspaceAdmin: http.StatusOK, // 200 — Administrator role has workspace.admin + ActorWorkspaceTester: http.StatusNotFound, + ActorSystemAdmin: http.StatusOK, // 200 }, }, } diff --git a/tests/matrix_helpers.go b/tests/matrix_helpers.go index bccbfda..3afc167 100644 --- a/tests/matrix_helpers.go +++ b/tests/matrix_helpers.go @@ -3,14 +3,11 @@ package tests import ( "fmt" "net/http" + "strings" "testing" "time" ) -// MatrixActor names a test subject in the permission matrix. Each actor -// represents a distinct caller profile (anonymous, role-X member, system -// admin, portal customer) that gets the same expected status for every cell -// of its assigned permission class. type MatrixActor string const ( @@ -22,86 +19,45 @@ const ( ActorWorkspaceAdmin MatrixActor = "ws_admin" ActorWorkspaceTester MatrixActor = "ws_tester" ActorSystemAdmin MatrixActor = "system_admin" - - // ActorPortalCustomer is deferred: the WIP change to - // internal/handlers/portal_customers.go (CreatePortalCustomer) now hard- - // requires a seeded 'Portal Customer' contact_role that does not exist - // in the schema. Every portal-customer integration test is currently - // red on the working tree (TestPortalCustomer_CannotAccessInternalEndpoints - // fails to create the customer with a 500). Once that seed is added back - // (or the lookup is reverted to its previous best-effort behavior), - // reintroduce ActorPortalCustomer here and add its expected statuses to - // every PermissionClass. - // ActorPortalCustomer MatrixActor = "portal_customer" ) -// MatrixActors is the canonical iteration order. Anything that walks the -// actor set should range over this slice, not the map, so subtest output is -// deterministic. +// These eight internal caller profiles are the current matrix scope. Portal +// customers and the implicit everyone-role path are not covered by this matrix. var MatrixActors = []MatrixActor{ - ActorAnonymous, - ActorNoMembership, - ActorCrossWorkspaceMember, - ActorWorkspaceViewer, - ActorWorkspaceEditor, - ActorWorkspaceAdmin, - ActorWorkspaceTester, - ActorSystemAdmin, - // ActorPortalCustomer deferred — see comment on the const block. + ActorAnonymous, ActorNoMembership, ActorCrossWorkspaceMember, + ActorWorkspaceViewer, ActorWorkspaceEditor, ActorWorkspaceAdmin, + ActorWorkspaceTester, ActorSystemAdmin, } -// MatrixSession is one actor's pre-built request transport. Do hits /api/* -// with whatever auth flavor the actor uses (session cookie, no-auth, portal -// bearer); the matrix test code never needs to know which. type MatrixSession struct { Name MatrixActor - UserID int // 0 for ActorAnonymous and ActorPortalCustomer - do func(t *testing.T, method, path string, body interface{}) *http.Response + UserID int + server *TestServer + bearer bool } -// Do dispatches the request through this actor's transport. -func (s *MatrixSession) Do(t *testing.T, method, path string, body interface{}) *http.Response { +// MatrixRoutes use the canonical session path. The bearer run exercises its +// mirrored REST mount with a real bearer token, never a session-cookie fallback. +func (s *MatrixSession) Do(t *testing.T, method, path string, body any) *http.Response { t.Helper() - return s.do(t, method, path, body) + if !strings.HasPrefix(path, "/api/v2/") { + t.Fatalf("matrix representative must target canonical v2 path: %q", path) + } + endpoint := strings.TrimPrefix(path, "/api/v2") + if s.bearer { + return MakeV2BearerRequest(t, s.server, method, endpoint, body) + } + return MakeV2SessionRequest(t, s.server, method, endpoint, body) } -// MatrixSubjects bundles the workspace fixtures every actor is configured -// against. TargetWorkspaceID is what each matrix cell hits; OtherWorkspaceID -// exists so ActorCrossWorkspaceMember has a real role somewhere else and -// is still rejected on the target. type MatrixSubjects struct { TargetWorkspaceID int TargetWorkspaceKey string OtherWorkspaceID int } -// MatrixFixtures bundles every seeded ID an ExpandMatrixPath template might -// reference. The matrix test seeds these once (workspaces, item, comment, -// attachment, link, ...) and reuses them across every (class, actor) cell. -// -// Fields are zero-valued when the corresponding fixture hasn't been seeded -// yet; route templates that reference an unseeded ID will produce "/0" path -// segments, which is intentionally invalid so the green-up pass surfaces -// missing fixtures as 4xx mismatches rather than silent passes. -// -// Scope of the matrix — locked-down workspaces only: -// -// TestPermissionMatrix runs against a workspace that LockDownWorkspace -// (helpers.go) has explicitly assigned at least one role to. That assignment -// sets wsExplicit[viewerRoleID] = true in permission_cache.go:1024, which -// zeroes the WorkspaceEveryone bonus map (permission_cache.go:1026). In this -// configuration, each actor gets only the permissions of the role(s) -// explicitly granted to them — no "open workspace" everyone bonus. -// -// The Viewer→Editor→Tester everyone-bonus chain in permission_cache.go:962 -// is therefore NOT exercised by this matrix. In a real workspace where -// roles are unassigned (the default state for new workspaces), Tester -// users would inherit Editor's perms via that chain — but the matrix -// asserts the strictest configuration only. -// -// Covering the open-workspace path is a separate initiative (see plan -// Slice 9). The status codes encoded in matrix_classes.go reflect the -// locked-down state. +// Every item/workspace used in the matrix is created through production HTTP. +// Both workspaces have explicit membership before actors are configured. type MatrixFixtures struct { TargetWorkspaceID int OtherWorkspaceID int @@ -113,116 +69,36 @@ type MatrixFixtures struct { PortalSlug string } -// SetupMatrixActors builds one MatrixSession per actor, assuming the caller -// has already: -// - started the test server, -// - called CreateBearerToken to seed the admin user + bearer token, -// - created the target and "other" workspaces, -// - locked the target workspace down so that workspace-permission denial -// manifests as 404 (the security policy invariant the matrix exists to -// guard). -// -// Reused helpers: -// - CreateTestUserWithCredentials for human actors -// - AssignWorkspaceRole for role-bound actors -// - GrantGlobalPermission for ActorSystemAdmin -// - CreateBearerTokenForUser (returns session cookie — misnamed but stable) -// - CreatePortalCustomerWithSession for ActorPortalCustomer -// - MakeAuthRequestWithToken / MakeUnauthenticatedRequest / MakePortalRequest for transport -func SetupMatrixActors(t *testing.T, server *TestServer, subj MatrixSubjects) map[MatrixActor]*MatrixSession { +func SetupMatrixActors(t *testing.T, server *TestServer, subj MatrixSubjects, bearer bool) map[MatrixActor]*MatrixSession { t.Helper() - - // Short suffix (last 8 nanos) keeps usernames under the 32-char limit - // while remaining unique across parallel matrix runs. - suffix := fmt.Sprintf("%d", time.Now().UnixNano()) - if len(suffix) > 8 { - suffix = suffix[len(suffix)-8:] - } - sessions := map[MatrixActor]*MatrixSession{} - - // Anonymous: no auth header at all. - sessions[ActorAnonymous] = &MatrixSession{ - Name: ActorAnonymous, - do: func(t *testing.T, method, path string, body interface{}) *http.Response { - return MakeUnauthenticatedRequest(t, server, method, path, body) - }, - } - - // NoMembership: authenticated but assigned no role on the target workspace. - // Since the target is locked down, a missing role manifests as 404. - noMemberID, noMemberUser, noMemberPass := CreateTestUserWithCredentials( - t, server, - "mx_nomemb_"+suffix, - "mx_nomemb_"+suffix+"@test.com", - ) - noMemberCookie := CreateBearerTokenForUser(t, server, noMemberUser, noMemberPass) - sessions[ActorNoMembership] = &MatrixSession{ - Name: ActorNoMembership, - UserID: noMemberID, - do: func(t *testing.T, method, path string, body interface{}) *http.Response { - return MakeAuthRequestWithToken(t, server, noMemberCookie, method, path, body) - }, - } - - // CrossWorkspaceMember: Editor on OtherWorkspaceID, no role on target. - // Must still be rejected on the target workspace. - crossID, crossUser, crossPass := CreateTestUserWithCredentials( - t, server, - "mx_cross_"+suffix, - "mx_cross_"+suffix+"@test.com", - ) - AssignWorkspaceRole(t, server, crossID, subj.OtherWorkspaceID, "Editor") - crossCookie := CreateBearerTokenForUser(t, server, crossUser, crossPass) - sessions[ActorCrossWorkspaceMember] = &MatrixSession{ - Name: ActorCrossWorkspaceMember, - UserID: crossID, - do: func(t *testing.T, method, path string, body interface{}) *http.Response { - return MakeAuthRequestWithToken(t, server, crossCookie, method, path, body) - }, + add := func(name MatrixActor, id int, cookie, token string) { + actorServer := *server + actorServer.SessionCookie, actorServer.BearerToken = cookie, token + sessions[name] = &MatrixSession{Name: name, UserID: id, server: &actorServer, bearer: bearer} } - - // Workspace role actors: Viewer, Editor, Administrator, Tester. + add(ActorAnonymous, 0, "", "") + suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000) for _, spec := range []struct { - actor MatrixActor - short string - roleName string + actor MatrixActor + short, role string + workspace int }{ - {ActorWorkspaceViewer, "view", "Viewer"}, - {ActorWorkspaceEditor, "edit", "Editor"}, - {ActorWorkspaceAdmin, "wadm", "Administrator"}, - {ActorWorkspaceTester, "test", "Tester"}, + {ActorNoMembership, "none", "", 0}, + {ActorCrossWorkspaceMember, "cross", "Editor", subj.OtherWorkspaceID}, + {ActorWorkspaceViewer, "view", "Viewer", subj.TargetWorkspaceID}, + {ActorWorkspaceEditor, "edit", "Editor", subj.TargetWorkspaceID}, + {ActorWorkspaceAdmin, "admin", "Administrator", subj.TargetWorkspaceID}, + {ActorWorkspaceTester, "test", "Tester", subj.TargetWorkspaceID}, } { - id, uname, pass := CreateTestUserWithCredentials( - t, server, - fmt.Sprintf("mx_%s_%s", spec.short, suffix), - fmt.Sprintf("mx_%s_%s@test.com", spec.short, suffix), - ) - AssignWorkspaceRole(t, server, id, subj.TargetWorkspaceID, spec.roleName) - cookie := CreateBearerTokenForUser(t, server, uname, pass) - sessions[spec.actor] = &MatrixSession{ - Name: spec.actor, - UserID: id, - do: func(t *testing.T, method, path string, body interface{}) *http.Response { - return MakeAuthRequestWithToken(t, server, cookie, method, path, body) - }, + name := "mx_" + spec.short + "_" + suffix + id, username, password := CreateTestUserWithCredentials(t, server, name, name+"@example.test") + if spec.role != "" { + AssignWorkspaceRole(t, server, id, spec.workspace, spec.role) } + cookie, token := CreateAuthCredentialsForUser(t, server, username, password) + add(spec.actor, id, cookie, token) } - - // SystemAdmin: the admin user that CreateBearerToken set up. Reuse the - // admin session cookie stashed on the test server. - sessions[ActorSystemAdmin] = &MatrixSession{ - Name: ActorSystemAdmin, - UserID: 0, // admin user id not tracked on TestServer; matrix cells don't need it - do: func(t *testing.T, method, path string, body interface{}) *http.Response { - return MakeAuthRequest(t, server, method, path, body) - }, - } - - // ActorPortalCustomer setup is deferred (see comment on the const). - // When the 'Portal Customer' contact_role seed is restored, reinstate: - // _, portalToken := CreatePortalCustomerWithSession(t, server, ...) - // sessions[ActorPortalCustomer] = &MatrixSession{...MakePortalRequest...} - + add(ActorSystemAdmin, lookupAdminUser(t, server).ID, server.SessionCookie, server.BearerToken) return sessions } diff --git a/tests/matrix_route_enumerator.go b/tests/matrix_route_enumerator.go index b4c3e0b..e16d4ab 100644 --- a/tests/matrix_route_enumerator.go +++ b/tests/matrix_route_enumerator.go @@ -10,11 +10,13 @@ import ( "sort" "strings" "testing" + + v2 "windshift/internal/restapi/v2" ) // RegisteredRoute is a (method, path) pair harvested from the source by -// walking the routes/ package's AST. Path always includes the surface -// prefix ("/api" or "/rest/api/v1") so the matrix tests and +// walking legacy registration ASTs and the canonical v2 inventory. Path +// includes the mounted surface prefix so the matrix tests and // drift-guards can compare directly against incoming request paths. type RegisteredRoute struct { Method string @@ -22,12 +24,14 @@ type RegisteredRoute struct { } // EnumerateRegisteredRoutes returns every HTTP route registered by the -// running server, parsed from the source tree. It walks two surfaces: +// running server, from legacy registration source and canonical v2 metadata: // // 1. internal/restapi/v1/router.go — calls to HandleWithMiddleware // (string-literal first arg) — prefix /rest/api/v1 // 2. internal/routes/*.go — calls to .HandleH / .Handle on // the `api` route group — prefix /api +// 3. internal/restapi/v2.Inventory() — /api/v2 and /rest/api/v2, +// according to each operation's Exposure. // // Direct mux.Handle("METHOD /path", ...) registrations in server.go (the // logbook/LLM proxies) are intentionally excluded — they bypass the @@ -45,6 +49,18 @@ func EnumerateRegisteredRoutes(t *testing.T) []RegisteredRoute { var all []RegisteredRoute all = append(all, parseV1Router(t, filepath.Join(coreRoot, "internal", "restapi", "v1", "router.go"))...) all = append(all, parseRoutesPackage(t, filepath.Join(coreRoot, "internal", "routes"))...) + for _, route := range v2.Inventory() { + switch route.Exposure { + case v2.ExposureBoth: + all = append(all, RegisteredRoute{route.Method, "/api/v2" + route.Path}, RegisteredRoute{route.Method, "/rest/api/v2" + route.Path}) + case v2.ExposureSession: + all = append(all, RegisteredRoute{route.Method, "/api/v2" + route.Path}) + case v2.ExposureBearer: + all = append(all, RegisteredRoute{route.Method, "/rest/api/v2" + route.Path}) + default: + t.Fatalf("unrecognized v2 exposure %q for %s %s", route.Exposure, route.Method, route.Path) + } + } // Stable order — both for deterministic subtest names and for diff // readability when the route table grows. diff --git a/tests/matrix_route_enumerator_test.go b/tests/matrix_route_enumerator_test.go index 4710964..de1ea84 100644 --- a/tests/matrix_route_enumerator_test.go +++ b/tests/matrix_route_enumerator_test.go @@ -3,6 +3,8 @@ package tests import ( "strings" "testing" + + v2 "windshift/internal/restapi/v2" ) // TestEnumerateRegisteredRoutes_Smoke is a sanity check that the AST walker @@ -21,8 +23,10 @@ func TestEnumerateRegisteredRoutes_Smoke(t *testing.T) { // that silently kills one prefix (e.g. by renaming the route group var) // gets caught. want := []struct{ method, path string }{ - {"GET", "/api/items/{id}"}, // legacy /api surface, items routes file - {"GET", "/rest/api/v1/items/{id}"}, // v1 bearer surface + {"POST", "/api/auth/login"}, // retained bootstrap surface + {"GET", "/rest/api/v1/items/{id}"}, // v1 bearer surface + {"GET", "/api/v2/items/{item_id}"}, // canonical session surface + {"GET", "/rest/api/v2/items/{item_id}"}, // canonical bearer surface } for _, w := range want { if !containsRoute(routes, w.method, w.path) { @@ -34,6 +38,19 @@ func TestEnumerateRegisteredRoutes_Smoke(t *testing.T) { } } +func TestEnumerateRegisteredRoutesRespectsV2Exposure(t *testing.T) { + routes := EnumerateRegisteredRoutes(t) + for _, route := range v2.Inventory() { + session := containsRoute(routes, route.Method, "/api/v2"+route.Path) + bearer := containsRoute(routes, route.Method, "/rest/api/v2"+route.Path) + wantSession := route.Exposure == v2.ExposureBoth || route.Exposure == v2.ExposureSession + wantBearer := route.Exposure == v2.ExposureBoth || route.Exposure == v2.ExposureBearer + if session != wantSession || bearer != wantBearer { + t.Errorf("%s %s: session=%t bearer=%t for exposure %s", route.Method, route.Path, session, bearer, route.Exposure) + } + } +} + func containsRoute(routes []RegisteredRoute, method, path string) bool { for _, r := range routes { if r.Method == method && r.Path == path { diff --git a/tests/matrix_routes.go b/tests/matrix_routes.go index 2035d1c..0df0127 100644 --- a/tests/matrix_routes.go +++ b/tests/matrix_routes.go @@ -25,14 +25,12 @@ type MatrixRoute struct { Body func(fx MatrixFixtures) any } -// MatrixRoutes is the seed of the full per-route classification. The -// drift-guard test (TestRouteClassification, future commit) will fail when -// a registered route has no entry here, forcing each new route to be -// explicitly classified. +// MatrixRoutes is the seed of the full per-route classification. +// TestRouteClassification requires an entry or documented exemption for +// registered routes under EnforcedPrefixes, not for every v2 endpoint. // -// Currently seeded with the GET /items/{id} representative for -// workspace.item.view. Future commits expand this list to cover the full -// /api/* and /rest/api/v1/* surface (~944 routes). +// Four v2 representatives run against both mounts. Other entries are policy +// intent only; prefix-scoped route enforcement remains explicitly incremental. var MatrixRoutes = []MatrixRoute{ // --- Representatives (exercised by TestPermissionMatrix) --- // @@ -42,21 +40,20 @@ var MatrixRoutes = []MatrixRoute{ // fired against every actor; further entries with the same Class are // policy-intent declarations only (they satisfy the drift guard but // are not re-tested for status code). - {Method: "GET", Path: "/api/items/{id}", Class: "workspace.item.view"}, + {Method: "GET", Path: "/api/v2/items/{id}", Class: "workspace.item.view"}, { - Method: "PUT", - Path: "/api/items/{id}", + Method: "PATCH", + Path: "/api/v2/items/{id}", Class: "workspace.item.edit", - // Items.Update parses the body before auth — a nil body would 400 - // and drown the 401/403/404 signal. Empty-but-valid JSON keeps the - // update a no-op title set; idempotent across actor reruns. + // The driver makes mutation text unique per actor and verifies stored + // state after every allow/deny; this is not a status-only no-op PATCH. Body: func(fx MatrixFixtures) any { return map[string]any{"title": "Matrix updated"} }, }, { Method: "POST", - Path: "/api/items/{id}/comments", + Path: "/api/v2/items/{id}/comments", Class: "workspace.item.comment", // CreateComment validates Content as non-empty. Each successful // call creates a new comment row; safe to run across all actors. @@ -65,13 +62,13 @@ var MatrixRoutes = []MatrixRoute{ }, }, { - Method: "PUT", - // Registered as `PUT /workspaces/{id}` but `{id}` here would resolve + Method: "PATCH", + // Registered as `PATCH /workspaces/{workspace_id}`; `{id}` would resolve // to the item ID via the placeholder convention; use {workspaceId} // so ExpandMatrixPath substitutes TargetWorkspaceID. Go's mux is // positional — the placeholder name in the URL doesn't have to // match the registration. - Path: "/api/workspaces/{workspaceId}", + Path: "/api/v2/workspaces/{workspaceId}", Class: "workspace.admin", // Workspace.Update validates Name as `required` — minimum body must // include it. active=true keeps the workspace usable for any later @@ -85,6 +82,12 @@ var MatrixRoutes = []MatrixRoute{ }, // --- Policy-intent classifications (drift guard only, not re-exercised) --- + // Local iteration collection policy is exercised independently by + // TestV2Iterations_WorkspaceAndGlobalPermissions on both mounts. + {Method: "GET", Path: "/api/v2/workspaces/{workspace_id}/iterations", Class: "workspace.item.view"}, + {Method: "POST", Path: "/api/v2/workspaces/{workspace_id}/iterations", Class: "workspace.item.edit"}, + {Method: "GET", Path: "/rest/api/v2/workspaces/{workspace_id}/iterations", Class: "workspace.item.view"}, + {Method: "POST", Path: "/rest/api/v2/workspaces/{workspace_id}/iterations", Class: "workspace.item.edit"}, // // Routes below share a policy class with one of the representatives above. // Each entry asserts "this route's auth gate follows class X" — the matrix @@ -94,6 +97,14 @@ var MatrixRoutes = []MatrixRoute{ // per-actor fixture refresh; see comment below). // workspace.item.view — routes gated on canViewItem / PermissionItemView. + // V2 diagram policy intent only: the representative tests do not re-fire + // every route below. + // Dedicated TestV2ItemDiagrams cases exercise both mounts; bearer scopes are + // checked separately by TestV2Diagrams_GranularBearerScopes. + {Method: "GET", Path: "/api/v2/items/{item_id}/diagrams", Class: "workspace.item.view"}, + {Method: "GET", Path: "/api/v2/item-diagrams/{diagram_id}", Class: "workspace.item.view"}, + {Method: "GET", Path: "/rest/api/v2/items/{item_id}/diagrams", Class: "workspace.item.view"}, + {Method: "GET", Path: "/rest/api/v2/item-diagrams/{diagram_id}", Class: "workspace.item.view"}, {Method: "GET", Path: "/api/items/{id}/available-status-transitions", Class: "workspace.item.view"}, {Method: "GET", Path: "/api/items/{id}/detail-summary", Class: "workspace.item.view"}, {Method: "GET", Path: "/api/items/{id}/history", Class: "workspace.item.view"}, @@ -128,6 +139,10 @@ var MatrixRoutes = []MatrixRoute{ // consume the fixture (which would break subsequent actor iterations) // stay in exemptions with a structural reason until the per-actor // fixture refresh shape lands. + {Method: "POST", Path: "/api/v2/items/{item_id}/diagrams", Class: "workspace.item.edit"}, + {Method: "PATCH", Path: "/api/v2/item-diagrams/{diagram_id}", Class: "workspace.item.edit"}, + {Method: "POST", Path: "/rest/api/v2/items/{item_id}/diagrams", Class: "workspace.item.edit"}, + {Method: "PATCH", Path: "/rest/api/v2/item-diagrams/{diagram_id}", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/{id}/reparent-children", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/{id}/copy", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/{id}/transition", Class: "workspace.item.edit"}, @@ -160,6 +175,24 @@ var MatrixRoutes = []MatrixRoute{ {Method: "GET", Path: "/api/attachments/{attachmentId}/download", Class: "workspace.item.view"}, {Method: "GET", Path: "/api/attachments/{attachmentId}/thumbnail", Class: "workspace.item.view"}, + // V2 label policy intent. The catalog is global, but each catalog route + // authorizes its workspace context. Dedicated TestV2Labels cases exercise + // these routes independently of the four matrix representatives. + {Method: "GET", Path: "/api/v2/workspaces/{workspace_id}/labels", Class: "workspace.item.view"}, + {Method: "GET", Path: "/api/v2/workspaces/{workspace_id}/labels/{label_id}", Class: "workspace.item.view"}, + {Method: "POST", Path: "/api/v2/workspaces/{workspace_id}/labels", Class: "workspace.item.edit"}, + {Method: "PATCH", Path: "/api/v2/workspaces/{workspace_id}/labels/{label_id}", Class: "workspace.admin"}, + {Method: "GET", Path: "/api/v2/items/{item_id}/labels", Class: "workspace.item.view"}, + {Method: "PUT", Path: "/api/v2/items/{item_id}/labels", Class: "workspace.item.edit"}, + {Method: "POST", Path: "/api/v2/items/{item_id}/labels", Class: "workspace.item.edit"}, + {Method: "GET", Path: "/rest/api/v2/workspaces/{workspace_id}/labels", Class: "workspace.item.view"}, + {Method: "GET", Path: "/rest/api/v2/workspaces/{workspace_id}/labels/{label_id}", Class: "workspace.item.view"}, + {Method: "POST", Path: "/rest/api/v2/workspaces/{workspace_id}/labels", Class: "workspace.item.edit"}, + {Method: "PATCH", Path: "/rest/api/v2/workspaces/{workspace_id}/labels/{label_id}", Class: "workspace.admin"}, + {Method: "GET", Path: "/rest/api/v2/items/{item_id}/labels", Class: "workspace.item.view"}, + {Method: "PUT", Path: "/rest/api/v2/items/{item_id}/labels", Class: "workspace.item.edit"}, + {Method: "POST", Path: "/rest/api/v2/items/{item_id}/labels", Class: "workspace.item.edit"}, + // --- /api/links — gate on the parent item's view/edit perm --- // Create/delete require item.edit on the source/target item. // Search returns linkable items filtered by view perm (multi-workspace @@ -194,15 +227,8 @@ func RepresentativeRouteFor(className string) *MatrixRoute { // {linkId} → fx.TargetLinkID // {slug} → fx.PortalSlug (empty if unseeded) // -// The returned path is the endpoint argument passed to MakeAuthRequestWithToken -// (so it should start with /api or /rest/api/v1 to match how the helper -// constructs URLs — MakeAuthRequestWithToken concatenates APIBase which is -// already the host:port, with the leading "/api" already removed... see -// helpers.go:938 which does testServer.APIBase + endpoint). -// -// NOTE: The /api/* helpers in tests/helpers.go strip the /api prefix on the -// way in (APIBase ends in "/api"), so this function strips it from the route -// template before returning. +// The canonical full path is retained. MatrixSession.Do is responsible for +// mapping /api/v2 to the session or bearer mount exactly once. func ExpandMatrixPath(template string, fx MatrixFixtures) string { s := template s = strings.ReplaceAll(s, "{otherItemId}", strconv.Itoa(fx.OtherItemID)) @@ -217,9 +243,5 @@ func ExpandMatrixPath(template string, fx MatrixFixtures) string { s = strings.ReplaceAll(s, "{linkId}", strconv.Itoa(fx.TargetLinkID)) s = strings.ReplaceAll(s, "{slug}", fx.PortalSlug) - // MakeAuthRequestWithToken concatenates testServer.APIBase + endpoint, - // where APIBase already ends in "/api" (see StartTestServer). Strip the - // "/api" prefix from the template so we don't double it. - s = strings.TrimPrefix(s, "/api") return s } diff --git a/tests/mcp_time_test.go b/tests/mcp_time_test.go index 65f68aa..32ccca5 100644 --- a/tests/mcp_time_test.go +++ b/tests/mcp_time_test.go @@ -3,15 +3,15 @@ package tests import ( + "fmt" "io" "net/http" - "strconv" "testing" "time" ) // createTimeProjectFixture creates a customer + time project via -// /api/time/projects and returns the project ID. The MCP time tools rely on +// retained customer setup and session-v2 time projects. The MCP time tools rely on // at least one project being visible to the caller, and the // /time/projects POST handler requires a customer reference. func createTimeProjectFixture(t *testing.T, ts *TestServer, name string) int { @@ -35,15 +35,8 @@ func createTimeProjectFixture(t *testing.T, ts *TestServer, name string) int { "status": "Active", "customer_id": customerID, } - resp := MakeAuthRequest(t, ts, http.MethodPost, "/time/projects", body) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("create time project: %d - %s", resp.StatusCode, string(raw)) - } - var out map[string]interface{} - DecodeJSON(t, resp, &out) - return ExtractIDFromResponse(t, out) + out := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, ts, http.MethodPost, "/time/projects", body), http.StatusCreated) + return out.ID } func TestMCP_ListTimeProjects(t *testing.T) { @@ -119,7 +112,38 @@ func TestMCP_LogTime_AndList(t *testing.T) { if len(listed.Worklogs) != 1 || listed.Worklogs[0].DurationMinutes != 90 { t.Fatalf("list_worklogs after log_time: %+v", listed.Worklogs) } - _ = strconv.Itoa // satisfy unused import elimination if refactor changes shape +} + +// V2 returns Unix timestamps, unlike the MCP tool's local-time presentation. +// Check both the creation response and a fresh read so the conversion is not +// merely an unpersisted response decoration. +func assertV2ZurichWorklog(t *testing.T, ts *TestServer, response *http.Response) { + t.Helper() + type worklog struct { + ID int `json:"id"` + Date int64 `json:"date"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + DurationMinutes int `json:"duration_minutes"` + } + created := DecodeV2Document[worklog](t, response, http.StatusCreated) + if created.ID <= 0 { + t.Fatalf("created worklog has invalid ID: %+v", created) + } + want := worklog{ + ID: created.ID, + Date: time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC).Unix(), + StartTime: time.Date(2026, time.July, 14, 7, 0, 0, 0, time.UTC).Unix(), + EndTime: time.Date(2026, time.July, 14, 8, 0, 0, 0, time.UTC).Unix(), + DurationMinutes: 60, + } + if created != want { + t.Fatalf("REST civil-time interpretation = %+v, want %+v", created, want) + } + persisted := DecodeV2Document[worklog](t, MakeV2BearerRequest(t, ts, http.MethodGet, fmt.Sprintf("/time/worklogs/%d", created.ID), nil), http.StatusOK) + if persisted != want { + t.Fatalf("persisted REST worklog = %+v, want %+v", persisted, want) + } } func TestRESTAndMCPWorklogsUseExplicitCivilTimezoneWithoutPreOffset(t *testing.T) { @@ -127,7 +151,7 @@ func TestRESTAndMCPWorklogsUseExplicitCivilTimezoneWithoutPreOffset(t *testing.T CreateBearerToken(t, ts) pid := createTimeProjectFixture(t, ts, "Timezone Worklogs") - restResponse := MakeBearerRequest(t, ts, http.MethodPost, "/rest/api/v1/time/worklogs", map[string]interface{}{ + restResponse := MakeV2BearerRequest(t, ts, http.MethodPost, "/time/worklogs", map[string]interface{}{ "project_id": pid, "description": "REST Zurich wall clock", "date": "2026-07-14", @@ -135,13 +159,7 @@ func TestRESTAndMCPWorklogsUseExplicitCivilTimezoneWithoutPreOffset(t *testing.T "end_time": "10:00", "timezone": "Europe/Zurich", }) - defer restResponse.Body.Close() - AssertStatusCode(t, restResponse, http.StatusCreated) - var restOut map[string]interface{} - DecodeJSON(t, restResponse, &restOut) - if restOut["timezone"] != "Europe/Zurich" || restOut["start_time_local"] != "09:00" || restOut["start_at"] != "2026-07-14T07:00:00Z" { - t.Fatalf("REST interpretation = %+v", restOut) - } + assertV2ZurichWorklog(t, ts, restResponse) session := dialMCP(t, ts) var mcpOut struct { @@ -190,24 +208,18 @@ func TestRESTAndMCPWorklogsResolveTokenUserTimezoneWhenRequestOmitsIt(t *testing CreateBearerToken(t, ts) pid := createTimeProjectFixture(t, ts, "Stored Timezone Worklogs") - update := MakeAuthRequest(t, ts, http.MethodPut, "/users/1/regional-settings", map[string]interface{}{ + update := MakeAuthRequest(t, ts, http.MethodPut, fmt.Sprintf("/users/%d/regional-settings", lookupAdminUser(t, ts).ID), map[string]interface{}{ "timezone": "Europe/Zurich", "language": "en", }) defer update.Body.Close() AssertStatusCode(t, update, http.StatusOK) - restResponse := MakeBearerRequest(t, ts, http.MethodPost, "/rest/api/v1/time/worklogs", map[string]interface{}{ + restResponse := MakeV2BearerRequest(t, ts, http.MethodPost, "/time/worklogs", map[string]interface{}{ "project_id": pid, "description": "REST stored timezone", "date": "2026-07-14", "start_time": "09:00", "end_time": "10:00", }) - defer restResponse.Body.Close() - AssertStatusCode(t, restResponse, http.StatusCreated) - var restOut map[string]interface{} - DecodeJSON(t, restResponse, &restOut) - if restOut["timezone"] != "Europe/Zurich" || restOut["start_at"] != "2026-07-14T07:00:00Z" { - t.Fatalf("REST stored-timezone interpretation = %+v", restOut) - } + assertV2ZurichWorklog(t, ts, restResponse) session := dialMCP(t, ts) var mcpOut struct { diff --git a/tests/permission_matrix_test.go b/tests/permission_matrix_test.go index 16acd83..299022a 100644 --- a/tests/permission_matrix_test.go +++ b/tests/permission_matrix_test.go @@ -1,105 +1,262 @@ package tests -import "testing" +import ( + "fmt" + "net/http" + "reflect" + "strings" + "testing" -// TestPermissionMatrix asserts the (actor × permission-class) status matrix -// for the API surface. For each PermissionClass in Classes, the test: -// -// 1. Looks up the representative MatrixRoute for that class. -// 2. Walks every MatrixActor in deterministic order (MatrixActors). -// 3. Fires the route via that actor's session. -// 4. Asserts the response is *exactly* class.Expected[actor] — not -// "rejected" (401/403/404) blanket-acceptance. The point of this test -// is to catch drift from the security-policy codes documented in -// matrix_classes.go. -// -// Why this exists: permission tests today are distributed by feature as -// one-off t.Run subtests; there's no single place a reviewer can see "for -// every route, here is the expected status for every actor." A new route -// can land with the wrong status code (e.g. 403 instead of the -// 404-for-existence-privacy invariant) and no test catches it. This is -// that catch. -// -// Seeded with workspace.item.view (GET /items/{id}). Subsequent commits -// expand Classes and MatrixRoutes one resource family at a time. -func TestPermissionMatrix(t *testing.T) { - server, _ := StartTestServer(t, GetDBType()) - CreateBearerToken(t, server) - - // Two workspaces: target is what we test against; other gives - // ActorCrossWorkspaceMember a legitimate role somewhere so the - // rejection on target isn't "user has no role anywhere." - targetWS, targetKey := CreateTestWorkspace(t, server, "Matrix Target", shortKey("MTRX")) - otherWS, _ := CreateTestWorkspace(t, server, "Matrix Other", shortKey("MTRO")) - - // Lock down the target so that workspace-permission denial actually - // manifests as 404 (the open-by-default behaviour would let - // ActorNoMembership see the item, defeating the matrix). - LockDownWorkspace(t, server, targetWS) + "windshift/internal/models" +) - // One item per workspace so the GET /items/{id} representative has - // a concrete ID to target. - targetItem := CreateTestItem(t, server, targetWS, "Matrix target item") - otherItem := CreateTestItem(t, server, otherWS, "Matrix other item") - - actors := SetupMatrixActors(t, server, MatrixSubjects{ - TargetWorkspaceID: targetWS, - TargetWorkspaceKey: targetKey, - OtherWorkspaceID: otherWS, - }) +// Four representative classes x eight actors x two real mounts. This is not +// per-route coverage: other MatrixRoutes remain policy-intent declarations. +func TestPermissionMatrix(t *testing.T) { + if len(Classes) == 0 || len(MatrixActors) == 0 { + t.Fatal("matrix requires classes and actors") + } + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + targetWS, targetKey := CreateTestWorkspace(t, server, "Matrix Target", shortKey("MTRX")) + otherWS, _ := CreateTestWorkspace(t, server, "Matrix Other", shortKey("MTRO")) + LockDownWorkspace(t, server, targetWS) + LockDownWorkspace(t, server, otherWS) + createItem := func(workspace int, title string) int { + t.Helper() + item := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodPost, "/items", map[string]any{"workspace_id": workspace, "title": title}), http.StatusCreated) + if item.ID <= 0 { + t.Fatal("matrix fixture needs a positive item ID") + } + return item.ID + } + fixtures := MatrixFixtures{TargetWorkspaceID: targetWS, OtherWorkspaceID: otherWS, + TargetItemID: createItem(targetWS, "Matrix target item"), OtherItemID: createItem(otherWS, "Matrix other item")} + actors := SetupMatrixActors(t, server, MatrixSubjects{TargetWorkspaceID: targetWS, TargetWorkspaceKey: targetKey, OtherWorkspaceID: otherWS}, bearer) + // Prove this actor really has usable membership elsewhere; otherwise + // its target denial would merely duplicate the no-membership case. + otherItem := DecodeV2Document[v2FixtureRecord](t, actors[ActorCrossWorkspaceMember].Do(t, http.MethodGet, fmt.Sprintf("/api/v2/items/%d", fixtures.OtherItemID), nil), http.StatusOK) + if otherItem.ID != fixtures.OtherItemID { + t.Fatal("cross-workspace actor cannot read its own workspace item") + } - fixtures := MatrixFixtures{ - TargetWorkspaceID: targetWS, - OtherWorkspaceID: otherWS, - TargetItemID: targetItem, - OtherItemID: otherItem, + for _, class := range Classes { + t.Run(class.Name, func(t *testing.T) { + route := RepresentativeRouteFor(class.Name) + if route == nil { + t.Fatalf("no representative for %q", class.Name) + } + if len(class.Expected) != len(MatrixActors) || len(actors) != len(MatrixActors) { + t.Fatal("matrix actor/expectation coverage is incomplete") + } + for _, actorName := range MatrixActors { + expected, ok := class.Expected[actorName] + if !ok { + t.Fatalf("missing expectation for %s", actorName) + } + actor, ok := actors[actorName] + if !ok { + t.Fatalf("missing actor %s", actorName) + } + t.Run(string(actorName), func(t *testing.T) { + before := readMatrixState(t, server, fixtures, class.Name) + text := "Matrix " + string(actorName) + var body any + if route.Body != nil { + body = route.Body(fixtures) + fields, ok := body.(map[string]any) + if !ok { + t.Fatal("matrix mutation body must be a field map") + } + switch class.Name { + case "workspace.item.edit": + fields["title"] = text + case "workspace.item.comment": + fields["content"] = text + case "workspace.admin": + fields["name"] = text + default: + t.Fatalf("unexpected mutation class %s", class.Name) + } + } + path := ExpandMatrixPath(route.Path, fixtures) + response := actor.Do(t, route.Method, path, body) + if response.StatusCode != expected { + AssertStatusCode(t, response, expected) + response.Body.Close() + t.Fatalf("%s %s as %s did not satisfy class %s", route.Method, path, actorName, class.Name) + } + assertCommentJSON(t, response) + var result map[string]any + if expected >= 400 { + assertCommentStatus(t, response, expected) + } else { + result = DecodeV2Document[map[string]any](t, response, expected) + } + after := readMatrixState(t, server, fixtures, class.Name) + if expected >= 400 || class.Name == "workspace.item.view" { + if !reflect.DeepEqual(before, after) { + t.Fatal("denied/read-only matrix cell changed persisted state") + } + if result != nil && (result["id"] != float64(fixtures.TargetItemID) || result["title"] != before.Text) { + t.Fatal("allowed read returned wrong item") + } + return + } + switch class.Name { + case "workspace.item.edit", "workspace.admin": + field := "title" + if class.Name == "workspace.admin" { + field = "name" + } + if result["id"] != float64(before.ID) || result[field] != text || after.ID != before.ID || after.Text != text || after.Key != before.Key || after.Description != before.Description || after.Active != before.Active { + t.Fatal("allowed PATCH did not persist exactly the requested text") + } + case "workspace.item.comment": + id := ExtractIDFromResponse(t, result) + if id <= 0 || result["content"] != text || result["author_id"] != float64(actor.UserID) || len(after.Comments) != len(before.Comments)+1 { + t.Fatal("allowed comment lost identity, content, author or persistence") + } + prior := map[int]models.Comment{} + for _, comment := range before.Comments { + prior[comment.ID] = comment + } + found := false + for _, comment := range after.Comments { + if comment.ID == id { + if found || comment.Content != text || comment.AuthorID == nil || *comment.AuthorID != actor.UserID { + t.Fatal("stored matrix comment is invalid") + } + found = true + } else { + old, ok := prior[comment.ID] + if !ok || !reflect.DeepEqual(old, comment) { + t.Fatal("comment create changed an existing comment") + } + delete(prior, comment.ID) + } + } + if !found || len(prior) != 0 { + t.Fatal("comment feed omitted expected records") + } + } + }) + } + }) + } + if bearer { + t.Run("token_scope", func(t *testing.T) { assertMatrixScopeGuards(t, server, fixtures) }) + } + }) } +} +func assertMatrixScopeGuards(t *testing.T, server *TestServer, fx MatrixFixtures) { + t.Helper() + limited := *server + limited.SessionCookie = "" + limited.BearerToken = createTokenWithScopesAsUser(t, server, "admin", "testpass123", []string{"users:read"}) + caller := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, &limited, http.MethodGet, "/users/me", nil), http.StatusOK) + if caller.ID != lookupAdminUser(t, server).ID { + t.Fatal("scope control token is not the administrator") + } for _, class := range Classes { t.Run(class.Name, func(t *testing.T) { route := RepresentativeRouteFor(class.Name) - if route == nil { - t.Fatalf("no MatrixRoute classified into %q — add one to MatrixRoutes in matrix_routes.go", class.Name) + before := readMatrixState(t, server, fx, class.Name) + var body any + if route.Body != nil { + body = route.Body(fx) + } + path := strings.TrimPrefix(ExpandMatrixPath(route.Path, fx), "/api/v2") + response := MakeV2BearerRequest(t, &limited, route.Method, path, body) + defer response.Body.Close() + assertCommentJSON(t, response) + if response.StatusCode != http.StatusForbidden { + AssertStatusCode(t, response, http.StatusForbidden) + return + } + var failure struct { + Error struct{ Code string } `json:"error"` + } + DecodeJSON(t, response, &failure) + if failure.Error.Code != "insufficient_permission" { + t.Fatalf("scope error=%q, want insufficient_permission", failure.Error.Code) } + if after := readMatrixState(t, server, fx, class.Name); !reflect.DeepEqual(before, after) { + t.Fatal("scope-denied request changed stored state") + } + }) + } +} - for _, actorName := range MatrixActors { - expected, ok := class.Expected[actorName] - if !ok { - t.Errorf("class %q has no Expected entry for actor %q — every class must classify every actor", class.Name, actorName) - continue - } - session, ok := actors[actorName] - if !ok { - t.Fatalf("SetupMatrixActors did not produce session for %q", actorName) - } +type matrixState struct { + ID int + Text, Key, Description string + Active bool + Comments []models.Comment +} - t.Run(string(actorName), func(t *testing.T) { - path := ExpandMatrixPath(route.Path, fixtures) - var body any - if route.Body != nil { - body = route.Body(fixtures) - } - resp := session.Do(t, route.Method, path, body) - defer resp.Body.Close() +// Read stored state independently through the administrator session after each +// matrix request; the bearer run therefore also verifies cross-mount persistence. +func readMatrixState(t *testing.T, server *TestServer, fx MatrixFixtures, class string) matrixState { + t.Helper() + switch class { + case "workspace.item.view", "workspace.item.edit": + item := DecodeV2Document[models.Item](t, MakeV2SessionRequest(t, server, http.MethodGet, fmt.Sprintf("/items/%d", fx.TargetItemID), nil), http.StatusOK) + return matrixState{ID: item.ID, Text: item.Title, Description: item.Description} + case "workspace.item.comment": + feed := DecodeV2Document[v2CommentFeed](t, MakeV2SessionRequest(t, server, http.MethodGet, fmt.Sprintf("/items/%d/comments", fx.TargetItemID), nil), http.StatusOK) + if feed.HasMore { + t.Fatal("matrix comment snapshot would be truncated") + } + return matrixState{Comments: feed.Comments} + case "workspace.admin": + workspace := DecodeV2Document[models.Workspace](t, MakeV2SessionRequest(t, server, http.MethodGet, fmt.Sprintf("/workspaces/%d", fx.TargetWorkspaceID), nil), http.StatusOK) + return matrixState{ID: workspace.ID, Text: workspace.Name, Key: workspace.Key, Description: workspace.Description, Active: workspace.Active} + default: + t.Fatalf("missing state assertion for matrix class %s", class) + return matrixState{} + } +} - if resp.StatusCode != expected { - t.Errorf("%s %s as %s: expected %d, got %d (class=%s)", - route.Method, path, actorName, expected, resp.StatusCode, class.Name) - // Surface body on mismatch for easier debugging. - AssertStatusCode(t, resp, expected) - } - }) - } - }) +func TestExpandMatrixPathRetainsMount(t *testing.T) { + fixtures := MatrixFixtures{TargetItemID: 17, TargetWorkspaceID: 29, OtherItemID: 31, OtherWorkspaceID: 43, TargetCommentID: 47, TargetAttachmentID: 53, TargetLinkID: 59, PortalSlug: "matrix-portal"} + for _, tc := range []struct{ path, want string }{ + {"/api/v2/items/{id}", "/api/v2/items/17"}, + {"/api/v2/workspaces/{workspace_id}", "/api/v2/workspaces/29"}, + {"/rest/api/v2/items/{otherItemId}", "/rest/api/v2/items/31"}, + {"/api/v2/workspaces/{otherWorkspaceId}", "/api/v2/workspaces/43"}, + {"/api/v2/workspaces/{workspaceId}/items/{item_id}", "/api/v2/workspaces/29/items/17"}, + {"/api/v2/items/{itemId}/comments/{commentId}", "/api/v2/items/17/comments/47"}, + {"/api/attachments/{attachmentId}", "/api/attachments/53"}, + {"/api/v2/links/{linkId}", "/api/v2/links/59"}, + {"/api/portal/{slug}", "/api/portal/matrix-portal"}, + } { + if got := ExpandMatrixPath(tc.path, fixtures); got != tc.want { + t.Fatalf("path=%q, want %q", got, tc.want) + } } +} - // Final coverage sanity: every class in Classes must also have a - // representative MatrixRoute. The loop above already fails for missing - // routes; this is a more obvious "missing class" message in case the - // list grows out of sync. +// Prefix enforcement is still incremental; this guard independently ensures +// every exercised representative exists on BOTH real v2 mounts. +func TestMatrixV2RepresentativesRegistered(t *testing.T) { + registered := map[string]bool{} + for _, route := range EnumerateRegisteredRoutes(t) { + registered[routeKey(route.Method, route.Path)] = true + } for _, class := range Classes { - if RepresentativeRouteFor(class.Name) == nil { - t.Errorf("PermissionClass %q has no representative MatrixRoute", class.Name) + route := RepresentativeRouteFor(class.Name) + if route == nil || !strings.HasPrefix(route.Path, "/api/v2/") { + t.Fatalf("class %s lacks a canonical v2 representative", class.Name) + } + for _, path := range []string{route.Path, strings.Replace(route.Path, "/api/v2/", "/rest/api/v2/", 1)} { + if !registered[routeKey(route.Method, path)] { + t.Errorf("matrix representative is not registered: %s %s", route.Method, path) + } } } } diff --git a/tests/route_classification_exemptions.go b/tests/route_classification_exemptions.go index 58f31a3..50f09a6 100644 --- a/tests/route_classification_exemptions.go +++ b/tests/route_classification_exemptions.go @@ -34,6 +34,84 @@ type RouteClassificationExemption struct { // add to this list only when the route genuinely doesn't fit any policy // class. var RouteClassificationExemptions = []RouteClassificationExemption{ + // Iteration pre-registration: global and object-dependent scope do not + // share one workspace-only class. No expanded EnforcedPrefixes claim. + {Method: "GET", Path: "/api/v2/iterations", Reason: "authenticated global-only catalog; TestV2Iterations_ListScopeAndStatus"}, + {Method: "POST", Path: "/api/v2/iterations", Reason: "global iteration.manage, not workspace edit; TestV2Iterations_WorkspaceAndGlobalPermissions"}, + {Method: "GET", Path: "/api/v2/iterations/{iteration_id}", Reason: "global authenticated read or local item.view; TestV2Iterations_WorkspaceAndGlobalPermissions"}, + {Method: "PATCH", Path: "/api/v2/iterations/{iteration_id}", Reason: "object-dependent global iteration.manage or local item.edit; TestV2Iterations_WorkspaceAndGlobalPermissions"}, + {Method: "DELETE", Path: "/api/v2/iterations/{iteration_id}", Reason: "object-dependent global/local mutation consumes fixture; TestV2Iterations_WorkspaceAndGlobalPermissions"}, + {Method: "GET", Path: "/api/v2/iterations/{iteration_id}/progress", Reason: "global/local read plus visible-workspace report filtering; basic progress in TestV2Iterations_CRUDAndProgress"}, + {Method: "GET", Path: "/rest/api/v2/iterations", Reason: "global-only catalog plus iterations:read; TestV2Iterations_GranularBearerScopes"}, + {Method: "POST", Path: "/rest/api/v2/iterations", Reason: "global iteration.manage plus iterations:write; TestV2Iterations_GranularBearerScopes"}, + {Method: "GET", Path: "/rest/api/v2/iterations/{iteration_id}", Reason: "object-dependent global/local read plus iterations:read; TestV2Iterations"}, + {Method: "PATCH", Path: "/rest/api/v2/iterations/{iteration_id}", Reason: "global/local mutation plus iterations:write; TestV2Iterations"}, + {Method: "DELETE", Path: "/rest/api/v2/iterations/{iteration_id}", Reason: "global/local mutation plus distinct iterations:delete; TestV2Iterations_GranularBearerScopes"}, + {Method: "GET", Path: "/rest/api/v2/iterations/{iteration_id}/progress", Reason: "global/local read and report filtering plus iterations:read; basic progress in TestV2Iterations"}, + // V2 priority pre-registration: global catalog and system-admin mutations + // are not workspace-role policies. Dedicated tests cover real endpoints. + {Method: "GET", Path: "/api/v2/priorities", Reason: "global authenticated catalog; TestV2Priorities"}, + {Method: "GET", Path: "/api/v2/priorities/{priority_id}", Reason: "global authenticated catalog; TestV2Priorities"}, + {Method: "POST", Path: "/api/v2/priorities", Reason: "system-admin catalog mutation; TestV2Priorities_AuthenticationAndAdminBoundaries"}, + {Method: "PATCH", Path: "/api/v2/priorities/{priority_id}", Reason: "system-admin catalog mutation; TestV2Priorities_AuthenticationAndAdminBoundaries"}, + {Method: "DELETE", Path: "/api/v2/priorities/{priority_id}", Reason: "system-admin deletion plus in-use guard; TestV2Priorities_DeleteInUse"}, + {Method: "GET", Path: "/rest/api/v2/priorities", Reason: "global catalog plus priorities:read; TestV2Priorities"}, + {Method: "GET", Path: "/rest/api/v2/priorities/{priority_id}", Reason: "global catalog plus priorities:read; TestV2Priorities"}, + {Method: "POST", Path: "/rest/api/v2/priorities", Reason: "system-admin mutation plus priorities:write; TestV2Priorities_AuthenticationAndAdminBoundaries"}, + {Method: "PATCH", Path: "/rest/api/v2/priorities/{priority_id}", Reason: "system-admin mutation plus priorities:write; TestV2Priorities_AuthenticationAndAdminBoundaries"}, + {Method: "DELETE", Path: "/rest/api/v2/priorities/{priority_id}", Reason: "system-admin deletion plus in-use guard and priorities:write; TestV2Priorities_DeleteInUse"}, + // V2 label/link pre-registration. Prefix enforcement remains legacy-scoped. + // Tests below check persisted effects and independent endpoint permissions; + // these declarations do not imply a complete permission-matrix migration. + {Method: "DELETE", Path: "/api/v2/workspaces/{workspace_id}/labels/{label_id}", Reason: "workspace.admin deletes global label and consumes assignments; TestV2Labels_CatalogMutationPermissions"}, + {Method: "DELETE", Path: "/rest/api/v2/workspaces/{workspace_id}/labels/{label_id}", Reason: "workspace.admin deletes global label and consumes assignments, plus token scope; TestV2Labels_CatalogMutationPermissions"}, + {Method: "DELETE", Path: "/api/v2/items/{item_id}/labels/{label_id}", Reason: "workspace.item.edit consumes assignment fixture; TestV2Labels_ItemAssignmentsAndPermissions"}, + {Method: "DELETE", Path: "/rest/api/v2/items/{item_id}/labels/{label_id}", Reason: "workspace.item.edit consumes assignment fixture, plus token scope; TestV2Labels_ItemAssignmentsAndPermissions"}, + {Method: "GET", Path: "/api/v2/items/{item_id}/links", Reason: "source visibility plus filtered endpoints; TestV2ItemLinks_SourceEditAndTargetView"}, + {Method: "GET", Path: "/rest/api/v2/items/{item_id}/links", Reason: "source visibility plus filtered endpoints and items:read; TestV2ItemLinks_SourceEditAndTargetView"}, + {Method: "POST", Path: "/api/v2/links", Reason: "cross-entity source-edit/target-view policy; item cases in TestV2ItemLinks"}, + {Method: "POST", Path: "/rest/api/v2/links", Reason: "cross-entity source-edit/target-view policy plus links:write; item cases in TestV2ItemLinks"}, + {Method: "DELETE", Path: "/api/v2/links/{link_id}", Reason: "cross-entity source permission and destructive fixture; item cases in TestV2ItemLinks"}, + {Method: "DELETE", Path: "/rest/api/v2/links/{link_id}", Reason: "cross-entity source permission and destructive fixture plus links:write; item cases in TestV2ItemLinks"}, + {Method: "GET", Path: "/api/v2/link-types", Reason: "global authenticated catalog, not workspace-scoped; TestV2LinkTypes"}, + {Method: "GET", Path: "/api/v2/link-types/{link_type_id}", Reason: "global authenticated catalog, not workspace-scoped; TestV2LinkTypes"}, + {Method: "POST", Path: "/api/v2/link-types", Reason: "system administrator catalog policy; TestV2LinkTypes_SystemAndAdminBoundaries"}, + {Method: "PATCH", Path: "/api/v2/link-types/{link_type_id}", Reason: "system administrator plus immutable system-type policy; TestV2LinkTypes_SystemAndAdminBoundaries"}, + {Method: "DELETE", Path: "/api/v2/link-types/{link_type_id}", Reason: "system administrator, system-type protection and in-use reference guard; TestV2LinkTypes"}, + {Method: "GET", Path: "/rest/api/v2/link-types", Reason: "global authenticated catalog plus links:read; TestV2LinkTypes"}, + {Method: "GET", Path: "/rest/api/v2/link-types/{link_type_id}", Reason: "global authenticated catalog plus links:read; TestV2LinkTypes"}, + {Method: "POST", Path: "/rest/api/v2/link-types", Reason: "system administrator catalog policy plus links:write; TestV2LinkTypes_SystemAndAdminBoundaries"}, + {Method: "PATCH", Path: "/rest/api/v2/link-types/{link_type_id}", Reason: "system administrator plus immutable system-type policy and links:write; TestV2LinkTypes_SystemAndAdminBoundaries"}, + {Method: "DELETE", Path: "/rest/api/v2/link-types/{link_type_id}", Reason: "system administrator, system-type protection and reference guard plus links:write; TestV2LinkTypes"}, + // V2 pre-registration only; EnforcedPrefixes still target legacy mounts. + // Page-level ACLs and immutable attachment replacement do not fit the + // workspace-only representative fixture. Dedicated TestV2PageDiagrams + // checks run on both mounts, with token scopes covered separately. + {Method: "GET", Path: "/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams", Reason: "per-page view ACL; TestV2PageDiagrams"}, + {Method: "POST", Path: "/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams", Reason: "per-page edit ACL and attachment creation; TestV2PageDiagrams"}, + {Method: "GET", Path: "/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams/{attachment_id}", Reason: "per-page view ACL plus attachment ownership; TestV2PageDiagrams"}, + {Method: "PATCH", Path: "/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams/{attachment_id}", Reason: "per-page edit ACL and immutable attachment replacement; TestV2PageDiagrams"}, + {Method: "GET", Path: "/rest/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams", Reason: "per-page view ACL plus token scope; TestV2PageDiagrams"}, + {Method: "POST", Path: "/rest/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams", Reason: "per-page edit ACL and attachment creation plus token scope; TestV2PageDiagrams"}, + {Method: "GET", Path: "/rest/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams/{attachment_id}", Reason: "per-page view ACL plus attachment ownership and token scope; TestV2PageDiagrams"}, + {Method: "PATCH", Path: "/rest/api/v2/workspaces/{workspace_id}/pages/{page_id}/diagrams/{attachment_id}", Reason: "per-page edit ACL and immutable attachment replacement plus token scope; TestV2PageDiagrams"}, + {Method: "DELETE", Path: "/api/v2/item-diagrams/{diagram_id}", Reason: "workspace.item.edit consumes diagram fixture; TestV2ItemDiagrams_CRUDAndWorkspacePermissions"}, + {Method: "DELETE", Path: "/rest/api/v2/item-diagrams/{diagram_id}", Reason: "workspace.item.edit consumes diagram fixture plus items:write; TestV2ItemDiagrams_CRUDAndWorkspacePermissions"}, + // Pre-register these exemptions for future v2-prefix enforcement; current + // EnforcedPrefixes remain legacy-scoped. Asset-set roles are independent of + // workspace roles. Dedicated v2 tests + // cover outsider/Viewer/Editor/Administrator on both mounts, token scopes + // separately, and persisted outcomes after each denied mutation. + {Method: "GET", Path: "/api/v2/asset-sets/{id}/assets", Reason: "asset-set role policy; TestV2Assets_SetPermissionsAreIndependentOfTokenScopes"}, + {Method: "POST", Path: "/api/v2/asset-sets/{id}/assets", Reason: "asset-set create policy; TestV2Assets_SetPermissionsAreIndependentOfTokenScopes"}, + {Method: "GET", Path: "/api/v2/assets/{id}", Reason: "asset-set view policy; TestV2Assets_SetPermissionsAreIndependentOfTokenScopes"}, + {Method: "PATCH", Path: "/api/v2/assets/{id}", Reason: "asset-set edit policy; TestV2Assets_SetPermissionsAreIndependentOfTokenScopes"}, + {Method: "DELETE", Path: "/api/v2/assets/{id}", Reason: "asset-set delete policy; TestV2Assets_SetPermissionsAreIndependentOfTokenScopes"}, + {Method: "GET", Path: "/rest/api/v2/asset-sets/{id}/assets", Reason: "asset-set role policy plus token scopes; dedicated TestV2Assets permission tests"}, + {Method: "POST", Path: "/rest/api/v2/asset-sets/{id}/assets", Reason: "asset-set create policy plus token scopes; dedicated TestV2Assets permission tests"}, + {Method: "GET", Path: "/rest/api/v2/assets/{id}", Reason: "asset-set view policy plus token scopes; dedicated TestV2Assets permission tests"}, + {Method: "PATCH", Path: "/rest/api/v2/assets/{id}", Reason: "asset-set edit policy plus token scopes; dedicated TestV2Assets permission tests"}, + {Method: "DELETE", Path: "/rest/api/v2/assets/{id}", Reason: "asset-set delete policy plus token scopes; dedicated TestV2Assets permission tests"}, // Multi-workspace filtered list endpoints. These do not return 404 on // permission denial — they return 200 with a result list filtered by // the caller's workspace memberships. The matrix's exact-status model diff --git a/tests/test_execution_authorization_contract_test.go b/tests/test_execution_authorization_contract_test.go index f040525..ac93e8e 100644 --- a/tests/test_execution_authorization_contract_test.go +++ b/tests/test_execution_authorization_contract_test.go @@ -7,11 +7,11 @@ import ( "testing" ) -func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { +func TestMCPAndRESTV2_TestExecutionAuthorizationContract(t *testing.T) { server, cleanup := StartTestServer(t, GetDBType()) defer cleanup() CreateBearerToken(t, server) - workspaceID, _ := CreateTestWorkspace(t, server, "Test execution auth", shortKey("TEA")) + workspaceID := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodPost, "/workspaces", map[string]any{"name": "Test execution auth", "key": "TEA"}), http.StatusCreated).ID LockDownWorkspace(t, server, workspaceID) testCaseID := seedTestCase(t, server, workspaceID, "Protected execution case") setID := seedTestSet(t, server, workspaceID, "Protected execution set") @@ -19,26 +19,25 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { createRun := func(t *testing.T, name string) (int, int) { t.Helper() - response := MakeAuthRequest(t, server, http.MethodPost, + response := MakeV2SessionRequest(t, server, http.MethodPost, fmt.Sprintf("/workspaces/%d/test-runs", workspaceID), - map[string]interface{}{"name": name, "set_id": setID}) - defer response.Body.Close() - AssertStatusCode(t, response, http.StatusCreated) - var run map[string]interface{} - DecodeJSON(t, response, &run) - runID := ExtractIDFromResponse(t, run) - - resultsResponse := MakeBearerRequest(t, server, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) - defer resultsResponse.Body.Close() - AssertStatusCode(t, resultsResponse, http.StatusOK) - var results []map[string]interface{} - DecodeJSON(t, resultsResponse, &results) + map[string]interface{}{"name": name, "plan_id": setID}) + runID := DecodeV2Document[v2FixtureRecord](t, response, http.StatusCreated).ID + + resultsResponse := MakeV2BearerRequest(t, server, http.MethodGet, + fmt.Sprintf("/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) + results := DecodeV2Document[[]map[string]interface{}](t, resultsResponse, http.StatusOK) if len(results) != 1 { t.Fatalf("run results = %v, want one result", results) } return runID, intField(results[0], "id") } + patchResult := func(t *testing.T, token string, runID, resultID int, body any) *http.Response { + t.Helper() + actor := *server + actor.BearerToken = token + return MakeV2BearerRequest(t, &actor, http.MethodPatch, fmt.Sprintf("/workspaces/%d/test-runs/%d/results/%d", workspaceID, runID, resultID), body) + } viewerID, viewerUsername, viewerPassword := CreateTestUserWithCredentials(t, server, "execution_viewer", "execution_viewer@test.com") AssignWorkspaceRole(t, server, viewerID, workspaceID, "Viewer") @@ -50,11 +49,11 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { token := createTokenWithScopesAsUser(t, server, testerUsername, testerPassword, []string{"mcp:access", "tests:read"}) - response := MakeBearerRequestWithToken(t, server, token, http.MethodPut, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results/%d", workspaceID, runID, resultID), + response := patchResult(t, token, runID, resultID, map[string]interface{}{"status": "passed"}) defer response.Body.Close() AssertStatusCode(t, response, http.StatusForbidden) + assertTestRunResultStatus(t, server, workspaceID, runID, resultID, "not_run") body, err := callMCPForContract(dialMCPWithToken(t, server, token), "record_test_result", map[string]interface{}{ "workspace_id": workspaceID, "run_id": runID, "test_case_id": testCaseID, "status": "passed", @@ -65,6 +64,7 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { if err != nil && !strings.Contains(err.Error(), "403") && !strings.Contains(err.Error(), "Forbidden") && !strings.Contains(err.Error(), "tests:write") && !strings.Contains(err.Error(), "scope") { t.Fatalf("MCP missing-scope error = %v", err) } + assertTestRunResultStatus(t, server, workspaceID, runID, resultID, "not_run") }) t.Run("viewer execution is existence-masked on both surfaces", func(t *testing.T) { @@ -72,11 +72,11 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { token := createTokenWithScopesAsUser(t, server, viewerUsername, viewerPassword, []string{"mcp:access", "tests:read", "tests:write"}) - response := MakeBearerRequestWithToken(t, server, token, http.MethodPut, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results/%d", workspaceID, runID, resultID), + response := patchResult(t, token, runID, resultID, map[string]interface{}{"status": "passed"}) defer response.Body.Close() AssertStatusCode(t, response, http.StatusNotFound) + assertTestRunResultStatus(t, server, workspaceID, runID, resultID, "not_run") body, err := callMCPForContract(dialMCPWithToken(t, server, token), "record_test_result", map[string]interface{}{ "workspace_id": workspaceID, "run_id": runID, "test_case_id": testCaseID, "status": "passed", @@ -96,8 +96,7 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { token := createTokenWithScopesAsUser(t, server, testerUsername, testerPassword, []string{"mcp:access", "tests:read", "tests:write"}) - response := MakeBearerRequestWithToken(t, server, token, http.MethodPut, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results/%d", workspaceID, runID, resultID), + response := patchResult(t, token, runID, resultID, map[string]interface{}{"status": "passed", "actual_result": "REST result"}) defer response.Body.Close() AssertStatusCode(t, response, http.StatusOK) @@ -119,12 +118,9 @@ func TestMCPAndRESTV1_TestExecutionAuthorizationContract(t *testing.T) { func assertTestRunResultStatus(t *testing.T, server *TestServer, workspaceID, runID, resultID int, want string) { t.Helper() - response := MakeBearerRequest(t, server, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) - defer response.Body.Close() - AssertStatusCode(t, response, http.StatusOK) - var results []map[string]interface{} - DecodeJSON(t, response, &results) + response := MakeV2BearerRequest(t, server, http.MethodGet, + fmt.Sprintf("/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) + results := DecodeV2Document[[]map[string]interface{}](t, response, http.StatusOK) for _, result := range results { if intField(result, "id") == resultID { if got, _ := result["status"].(string); got != want { diff --git a/tests/test_management_route_parity_test.go b/tests/test_management_route_parity_test.go deleted file mode 100644 index 6db6748..0000000 --- a/tests/test_management_route_parity_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package tests - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "testing" -) - -func TestV1TestManagementAggregateRoutes(t *testing.T) { - server, cleanup := StartTestServer(t, GetDBType()) - defer cleanup() - CreateBearerToken(t, server) - - workspaceID, _ := CreateTestWorkspace(t, server, "V1 aggregate routes", shortKey("V1AR")) - testCaseID := seedTestCase(t, server, workspaceID, "Aggregate route case") - - countResp := MakeBearerRequest(t, server, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-cases/count", workspaceID), nil) - defer countResp.Body.Close() - AssertStatusCode(t, countResp, http.StatusOK) - var countResult map[string]interface{} - DecodeJSON(t, countResp, &countResult) - if got := intField(countResult, "count"); got != 1 { - t.Fatalf("test-case count = %d, want 1", got) - } - - setID := seedTestSet(t, server, workspaceID, "Aggregate route set") - attachCaseToSet(t, server, workspaceID, setID, testCaseID) - createRunResp := MakeAuthRequest(t, server, http.MethodPost, - fmt.Sprintf("/workspaces/%d/test-runs", workspaceID), map[string]interface{}{ - "name": "Aggregate route run", - "set_id": setID, - }) - defer createRunResp.Body.Close() - AssertStatusCode(t, createRunResp, http.StatusCreated) - var run map[string]interface{} - DecodeJSON(t, createRunResp, &run) - runID := ExtractIDFromResponse(t, run) - - detailResp := MakeBearerRequest(t, server, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/detail", workspaceID, runID), nil) - defer detailResp.Body.Close() - AssertStatusCode(t, detailResp, http.StatusOK) - var detail struct { - Run map[string]interface{} `json:"run"` - TestCases []map[string]interface{} `json:"test_cases"` - } - DecodeJSON(t, detailResp, &detail) - if got := intField(detail.Run, "id"); got != runID { - t.Fatalf("detail run id = %d, want %d", got, runID) - } - if len(detail.TestCases) != 1 || intField(detail.TestCases[0], "id") != testCaseID { - t.Fatalf("detail test cases = %v, want case %d", detail.TestCases, testCaseID) - } -} - -func TestTestRunExecution_CookieAndRESTV1Contract(t *testing.T) { - server, cleanup := StartTestServer(t, GetDBType()) - defer cleanup() - CreateBearerToken(t, server) - workspaceID, _ := CreateTestWorkspace(t, server, "Execution parity", shortKey("EXP")) - testCaseID := seedTestCase(t, server, workspaceID, "Execution parity case") - setID := seedTestSet(t, server, workspaceID, "Execution parity set") - attachCaseToSet(t, server, workspaceID, setID, testCaseID) - - createResponse := MakeAuthRequest(t, server, http.MethodPost, - fmt.Sprintf("/workspaces/%d/test-runs", workspaceID), - map[string]interface{}{"name": "Parity run", "set_id": setID}) - defer createResponse.Body.Close() - AssertStatusCode(t, createResponse, http.StatusCreated) - var run map[string]interface{} - DecodeJSON(t, createResponse, &run) - runID := ExtractIDFromResponse(t, run) - if name, _ := run["name"].(string); name != "Parity run" { - t.Fatalf("shared create sanitizer produced name %q, want Parity run", name) - } - - resultsResponse := MakeAuthRequest(t, server, http.MethodGet, - fmt.Sprintf("/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) - defer resultsResponse.Body.Close() - AssertStatusCode(t, resultsResponse, http.StatusOK) - var results []map[string]interface{} - DecodeJSON(t, resultsResponse, &results) - if len(results) != 1 { - t.Fatalf("run results = %v, want one result", results) - } - resultID := intField(results[0], "id") - - cookieUpdate := MakeAuthRequest(t, server, http.MethodPut, - fmt.Sprintf("/workspaces/%d/test-runs/%d/results/%d", workspaceID, runID, resultID), - map[string]interface{}{ - "status": "failed", "actual_result": "cookie
result", - "notes": "cookie note", - }) - defer cookieUpdate.Body.Close() - AssertStatusCode(t, cookieUpdate, http.StatusOK) - - v1Read := MakeBearerRequest(t, server, http.MethodGet, - fmt.Sprintf("/rest/api/v1/workspaces/%d/test-runs/%d/results", workspaceID, runID), nil) - defer v1Read.Body.Close() - AssertStatusCode(t, v1Read, http.StatusOK) - var afterCookie []map[string]interface{} - DecodeJSON(t, v1Read, &afterCookie) - if len(afterCookie) != 1 || afterCookie[0]["status"] != "failed" { - t.Fatalf("v1 read after cookie update = %v", afterCookie) - } - actual, _ := afterCookie[0]["actual_result"].(string) - notes, _ := afterCookie[0]["notes"].(string) - if strings.Contains(actual, "Shared set", - "description": "beforeafter", - }) - defer createSetResponse.Body.Close() - AssertStatusCode(t, createSetResponse, http.StatusCreated) - var createdSet map[string]interface{} - DecodeJSON(t, createSetResponse, &createdSet) - setID := ExtractIDFromResponse(t, createdSet) - if createdSet["name"] != "Shared set" || strings.Contains(createdSet["description"].(string), "Updated set", - "description": "updateddescription", - }) - defer updateSetResponse.Body.Close() - AssertStatusCode(t, updateSetResponse, http.StatusOK) - - cookieSetResponse := MakeAuthRequest(t, server, http.MethodGet, - fmt.Sprintf("/workspaces/%d/test-sets/%d", workspaceID, setID), nil) - defer cookieSetResponse.Body.Close() - AssertStatusCode(t, cookieSetResponse, http.StatusOK) - var updatedSet map[string]interface{} - DecodeJSON(t, cookieSetResponse, &updatedSet) - if updatedSet["name"] != "Updated set" || strings.Contains(updatedSet["description"].(string), "Shared template", - "description": "before
after", - }) - defer createTemplateResponse.Body.Close() - AssertStatusCode(t, createTemplateResponse, http.StatusCreated) - var template map[string]interface{} - DecodeJSON(t, createTemplateResponse, &template) - templateID := ExtractIDFromResponse(t, template) - if template["name"] != "Shared template" || strings.Contains(template["description"].(string), "SafeTitle", "description": "BeforeAfter", + "asset_tag": "TAG-001", "asset_type_id": f.typeID, + }), http.StatusCreated) + id := ExtractIDFromResponse(t, created) + check := func(asset map[string]any) { + t.Helper() + for field, want := range map[string]string{"title": "SafeTitle", "description": "BeforeAfter", "asset_tag": "TAG-001"} { + AssertJSONField(t, asset, field, want) + } + } + check(created) + check(f.get(t, id)) + // Exercise PATCH as well as the create/read coverage retained from v1. + updated := DecodeV2Document[map[string]any](t, MakeV2BearerRequest(t, f.caller, http.MethodPatch, fmt.Sprintf("/assets/%d", id), map[string]any{ + "title": "SafeTitle", "description": "BeforeAfter", "asset_tag": "TAG-001", + }), http.StatusOK) + check(updated) + check(f.get(t, id)) +} + +func TestV2Assets_BearerAuditAttribution(t *testing.T) { + f := newV2AssetFixture(t) + id := ExtractIDFromResponse(t, f.create(t, "Attributed asset", nil)) + var rows []map[string]any + for _, row := range assetAuditEntries(t, f.admin, id) { + if row["action_type"] == "asset.create" { + rows = append(rows, row) + } + } + if len(rows) != 1 { + t.Fatalf("asset create audit count=%d, want one", len(rows)) + } + details, _ := rows[0]["details"].(map[string]any) + if details["auth_method"] != "bearer" { + t.Fatal("asset audit is not attributed to bearer authentication") + } + if tokenID, _ := details["api_token_id"].(float64); tokenID <= 0 { + t.Fatal("asset audit lacks a positive token ID") + } + prefix, _ := details["api_token_prefix"].(string) + // TokenPrefix is a display value with a trailing ellipsis, not raw bytes. + if len(prefix) <= 3 || !strings.HasSuffix(prefix, "...") || !strings.HasPrefix(f.caller.BearerToken, strings.TrimSuffix(prefix, "...")) { + t.Fatal("asset audit prefix does not match the acting token") + } +} + +func TestV2Assets_CustomFieldRequiredEnforcedOnCreate(t *testing.T) { + f := newV2AssetFixture(t) + fieldID := addRequiredFieldToType(t, f.admin, f.typeID, "hostname", "text") + response := MakeV2BearerRequest(t, f.caller, http.MethodPost, f.collection(), map[string]any{"title": "Missing hostname", "asset_type_id": f.typeID}) + expectAssetError(t, response, http.StatusBadRequest, "invalid_request", "hostname", "required") + f.assertCount(t, 0) + values := map[string]any{fmt.Sprint(fieldID): "host01"} + created := f.create(t, "With hostname", values) + for _, asset := range []map[string]any{created, f.get(t, ExtractIDFromResponse(t, created))} { + stored, _ := asset["custom_field_values"].(map[string]any) + if stored[fmt.Sprint(fieldID)] != "host01" { + t.Fatal("required hostname was not persisted") + } + } +} + +func TestV2Assets_CustomFieldTypeMismatchRejected(t *testing.T) { + f := newV2AssetFixture(t) + fieldID := addRequiredFieldToType(t, f.admin, f.typeID, "cpu_cores", "number") + expectAssetError(t, MakeV2BearerRequest(t, f.caller, http.MethodPost, f.collection(), map[string]any{ + "title": "TypeMismatch", "asset_type_id": f.typeID, "custom_field_values": map[string]any{fmt.Sprint(fieldID): "not-a-number"}, + }), http.StatusBadRequest, "invalid_request", fmt.Sprintf("custom_field_values[%q]: expected numeric value", fmt.Sprint(fieldID))) + f.assertCount(t, 0) + created := f.create(t, "Valid number", map[string]any{fmt.Sprint(fieldID): 8}) + stored, _ := f.get(t, ExtractIDFromResponse(t, created))["custom_field_values"].(map[string]any) + if stored[fmt.Sprint(fieldID)] != float64(8) { + t.Fatal("valid numeric field was not persisted") + } +} diff --git a/tests/v2_catalog_fixtures_test.go b/tests/v2_catalog_fixtures_test.go new file mode 100644 index 0000000..e808b3c --- /dev/null +++ b/tests/v2_catalog_fixtures_test.go @@ -0,0 +1,62 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +type catalogFixture struct { + admin, reader *TestServer + bearer bool +} + +func runCatalogCase(t *testing.T, test func(*testing.T, *catalogFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + _, username, password := CreateTestUserWithCredentials(t, admin, "catalog-reader", "catalog-reader@example.test") + cookie, token := CreateAuthCredentialsForUser(t, admin, username, password) + reader := *admin + reader.SessionCookie, reader.BearerToken = cookie, token + test(t, &catalogFixture{admin: admin, reader: &reader, bearer: bearer}) + }) + } +} + +func (f *catalogFixture) request(t *testing.T, actor *TestServer, method, path string, body any) *http.Response { + t.Helper() + var response *http.Response + if f.bearer { + response = MakeV2BearerRequest(t, actor, method, path, body) + } else { + response = MakeV2SessionRequest(t, actor, method, path, body) + } + if response.StatusCode != http.StatusNoContent { + assertCommentJSON(t, response) + } + return response +} + +func assertV2Error(t *testing.T, response *http.Response, status int, code string, messages ...string) { + t.Helper() + defer response.Body.Close() + AssertStatusCode(t, response, status) + assertCommentJSON(t, response) + var body struct { + Error struct { + Code, Message string + } `json:"error"` + } + DecodeJSON(t, response, &body) + if body.Error.Code != code { + t.Fatalf("v2 error code=%q, want %q", body.Error.Code, code) + } + for _, message := range messages { + if body.Error.Message != message { + t.Fatalf("v2 error message=%q, want %q", body.Error.Message, message) + } + } +} diff --git a/tests/v2_comments_test.go b/tests/v2_comments_test.go new file mode 100644 index 0000000..911698e --- /dev/null +++ b/tests/v2_comments_test.go @@ -0,0 +1,328 @@ +package tests + +import ( + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + "testing" + + "windshift/internal/models" +) + +type commentFixture struct { + admin, author *TestServer + workspace, item, authorID int + bearer bool +} + +type v2CommentFeed struct { + Comments []models.Comment `json:"comments"` + NextCursor string `json:"next_cursor"` + RefreshCursor string `json:"refresh_cursor"` + HasMore bool `json:"has_more"` + Total *int `json:"total"` +} + +func runCommentCase(t *testing.T, test func(*testing.T, *commentFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + f := &commentFixture{admin: admin, bearer: bearer} + f.workspace = DecodeV2Document[v2FixtureRecord](t, f.request(t, admin, http.MethodPost, "/workspaces", map[string]any{"name": "Comments", "key": "CMT"}), http.StatusCreated).ID + f.item = DecodeV2Document[v2FixtureRecord](t, f.request(t, admin, http.MethodPost, "/items", map[string]any{"workspace_id": f.workspace, "title": "Comment item"}), http.StatusCreated).ID + f.author, f.authorID = f.actor(t, "comment-author", "Editor") + test(t, f) + }) + } +} + +func (f *commentFixture) actor(t *testing.T, name, role string) (*TestServer, int) { + t.Helper() + id, username, password := CreateTestUserWithCredentials(t, f.admin, name, name+"@example.test") + AssignWorkspaceRole(t, f.admin, id, f.workspace, role) + cookie, token := CreateAuthCredentialsForUser(t, f.admin, username, password) + actor := *f.admin + actor.SessionCookie, actor.BearerToken = cookie, token + return &actor, id +} + +func (f *commentFixture) request(t *testing.T, actor *TestServer, method, path string, body any) *http.Response { + t.Helper() + if f.bearer { + return MakeV2BearerRequest(t, actor, method, path, body) + } + return MakeV2SessionRequest(t, actor, method, path, body) +} + +func (f *commentFixture) path() string { return fmt.Sprintf("/items/%d/comments", f.item) } +func commentPath(id int) string { return fmt.Sprintf("/comments/%d", id) } + +func (f *commentFixture) create(t *testing.T, content string) models.Comment { + t.Helper() + response := f.request(t, f.author, http.MethodPost, f.path(), map[string]any{"content": content}) + assertCommentJSON(t, response) + return DecodeV2Document[models.Comment](t, response, http.StatusCreated) +} + +func (f *commentFixture) feed(t *testing.T, query string) v2CommentFeed { + t.Helper() + response := f.request(t, f.admin, http.MethodGet, f.path()+query, nil) + assertCommentJSON(t, response) + return DecodeV2Document[v2CommentFeed](t, response, http.StatusOK) +} + +func assertCommentJSON(t *testing.T, response *http.Response) { + t.Helper() + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + response.Body.Close() + t.Fatalf("Content-Type=%q, want application/json", response.Header.Get("Content-Type")) + } +} + +func assertCommentStatus(t *testing.T, response *http.Response, status int, message ...string) { + t.Helper() + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != status { + t.Fatalf("status=%d, want %d: %s", response.StatusCode, status, body) + } + if status == http.StatusNoContent && len(body) != 0 { + t.Fatalf("204 response has body: %s", body) + } + if len(message) != 0 { + var envelope struct { + Error struct{ Code, Message string } `json:"error"` + } + if err := json.Unmarshal(body, &envelope); err != nil || envelope.Error.Code != "invalid_request" || envelope.Error.Message != message[0] { + t.Fatalf("validation error=%s, want invalid_request: %s", body, message[0]) + } + } +} + +func TestV2Comments_CreateComment_Success(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + c := f.create(t, "A comment") + if c.ID <= 0 || c.ItemID != f.item || c.Content != "A comment" || c.AuthorID == nil || *c.AuthorID != f.authorID { + t.Fatalf("comment=%+v", c) + } + }) +} + +func TestV2Comments_CreateComment_RejectsOversizedBody(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + for _, tc := range []struct{ size, status int }{{320 * 1024, 400}, {3 * 1024 * 1024, 413}} { + assertCommentStatus(t, f.request(t, f.author, http.MethodPost, f.path(), map[string]any{"content": strings.Repeat("x", tc.size)}), tc.status) + } + if feed := f.feed(t, ""); len(feed.Comments) != 0 { + t.Fatalf("oversized comment persisted: %+v", feed) + } + }) +} + +func TestV2Comments_CreateComment_ValidationErrors(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + for _, body := range []map[string]any{{}, {"content": ""}, {"content": " \n\t"}} { + assertCommentStatus(t, f.request(t, f.author, http.MethodPost, f.path(), body), http.StatusBadRequest, "content is required") + } + assertCommentStatus(t, f.request(t, f.author, http.MethodPost, f.path(), map[string]any{"content": "forged", "author_id": f.authorID}), http.StatusBadRequest) + if feed := f.feed(t, ""); len(feed.Comments) != 0 { + t.Fatalf("invalid comment persisted: %+v", feed) + } + }) +} + +func TestV2Comments_CreateComment_InvalidItemID(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + assertCommentStatus(t, f.request(t, f.author, http.MethodPost, "/items/abc/comments", map[string]any{"content": "test"}), http.StatusBadRequest) + }) +} + +func TestV2Comments_CreateComment_NonExistentItem(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/items/999999/comments", map[string]any{"content": "test"}), http.StatusNotFound) + }) +} + +func TestV2Comments_GetComments_Success(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + want := map[int]string{} + for i := 0; i < 3; i++ { + c := f.create(t, fmt.Sprintf("Comment %d", i)) + want[c.ID] = c.Content + } + feed := f.feed(t, "") + if len(feed.Comments) != 3 || feed.HasMore || feed.Total == nil || *feed.Total != 3 { + t.Fatalf("feed=%+v", feed) + } + for _, c := range feed.Comments { + if content, ok := want[c.ID]; !ok || content != c.Content || c.AuthorName == "" { + t.Fatalf("comment=%+v", c) + } + delete(want, c.ID) + } + }) +} + +func TestV2Comments_GetComments_DefaultPageIsBounded(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + want := map[int]bool{} + for i := 0; i < 26; i++ { + want[f.create(t, fmt.Sprintf("Comment %d", i)).ID] = true + } + first := f.feed(t, "") + if len(first.Comments) != 25 || !first.HasMore || first.Total == nil || *first.Total != 26 || first.NextCursor == "" || first.RefreshCursor == "" { + t.Fatalf("first=%+v", first) + } + last := f.feed(t, "?cursor="+url.QueryEscape(first.NextCursor)) + if len(last.Comments) != 1 || last.HasMore || last.NextCursor != "" || last.Total != nil { + t.Fatalf("last=%+v", last) + } + for _, page := range []v2CommentFeed{first, last} { + for _, c := range page.Comments { + if !want[c.ID] { + t.Fatalf("duplicate or unknown comment %d", c.ID) + } + delete(want, c.ID) + } + } + if len(want) != 0 { + t.Fatalf("missing comments: %v", want) + } + newest := f.create(t, "New comment") + refresh := f.feed(t, "?cursor="+url.QueryEscape(first.RefreshCursor)) + if len(refresh.Comments) != 1 || refresh.Comments[0].ID != newest.ID || refresh.HasMore || refresh.Total != nil { + t.Fatalf("refresh=%+v", refresh) + } + }) +} + +func TestV2Comments_GetComments_RejectsInvalidPagination(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + for _, query := range []string{"page_size=0", "page_size=101", "page_size=abc", "before=2025-01-01", "before_id=1", "since=invalid&since_id=1", "before_id=1&since_id=2", "cursor=invalid"} { + t.Run(query, func(t *testing.T) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, f.path()+"?"+query, nil), http.StatusBadRequest) + }) + } + }) +} + +func TestV2Comments_GetComments_EmptyList(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + feed := f.feed(t, "") + if feed.Comments == nil || len(feed.Comments) != 0 || feed.HasMore || feed.Total == nil || *feed.Total != 0 || feed.NextCursor != "" { + t.Fatalf("feed=%+v", feed) + } + }) +} + +func TestV2Comments_GetComments_NonExistentItem(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, "/items/999999/comments", nil), http.StatusNotFound) + }) +} + +func TestV2Comments_UpdateComment_Success(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + c := f.create(t, "original") + response := f.request(t, f.author, http.MethodPatch, commentPath(c.ID), map[string]any{"content": "updated"}) + assertCommentJSON(t, response) + updated := DecodeV2Document[models.Comment](t, response, http.StatusOK) + stored := DecodeV2Document[models.Comment](t, f.request(t, f.author, http.MethodGet, commentPath(c.ID), nil), http.StatusOK) + if updated.ID != c.ID || updated.Content != "updated" || stored.Content != "updated" { + t.Fatalf("updated=%+v stored=%+v", updated, stored) + } + }) +} + +func TestV2Comments_UpdateComment_EmptyContent(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + c := f.create(t, "original") + for _, body := range []map[string]any{{}, {"content": nil}, {"content": ""}, {"content": " \n\t"}} { + assertCommentStatus(t, f.request(t, f.author, http.MethodPatch, commentPath(c.ID), body), http.StatusBadRequest, "content is required") + } + stored := DecodeV2Document[models.Comment](t, f.request(t, f.author, http.MethodGet, commentPath(c.ID), nil), http.StatusOK) + if stored.Content != c.Content { + t.Fatalf("invalid update persisted: %+v", stored) + } + }) +} + +func TestV2Comments_UpdateComment_NotFound(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodPatch, "/comments/999999", map[string]any{"content": "updated"}), http.StatusNotFound) + }) +} + +func TestV2Comments_UpdateComment_NotAuthor(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { f.deniedMutation(t, http.MethodPatch) }) +} + +func (f *commentFixture) deniedMutation(t *testing.T, method string) { + t.Helper() + c := f.create(t, "original") + other, _ := f.actor(t, "comment-other", "Editor") + visible := DecodeV2Document[models.Comment](t, f.request(t, other, http.MethodGet, commentPath(c.ID), nil), http.StatusOK) + if visible.ID != c.ID { + t.Fatalf("visible=%+v", visible) + } + var body any + if method == http.MethodPatch { + body = map[string]any{"content": "forged"} + } + assertCommentStatus(t, f.request(t, other, method, commentPath(c.ID), body), http.StatusNotFound) + stored := DecodeV2Document[models.Comment](t, f.request(t, f.author, http.MethodGet, commentPath(c.ID), nil), http.StatusOK) + if stored.ID != c.ID || stored.Content != c.Content { + t.Fatalf("denied mutation persisted: %+v", stored) + } +} + +func TestV2Comments_DeleteComment_Success(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + c := f.create(t, "original") + assertCommentStatus(t, f.request(t, f.author, http.MethodDelete, commentPath(c.ID), nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.author, http.MethodGet, commentPath(c.ID), nil), http.StatusNotFound) + feed := f.feed(t, "") + if len(feed.Comments) != 0 || feed.Total == nil || *feed.Total != 0 { + t.Fatalf("delete did not persist: %+v", feed) + } + }) +} + +func TestV2Comments_DeleteComment_NotFound(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, "/comments/999999", nil), http.StatusNotFound) + }) +} + +func TestV2Comments_DeleteComment_NotAuthor(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { f.deniedMutation(t, http.MethodDelete) }) +} + +func TestV2Comments_InvalidID_Scenarios(t *testing.T) { + runCommentCase(t, func(t *testing.T, f *commentFixture) { + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/items/abc/comments", nil}, + {http.MethodPost, "/items/abc/comments", map[string]any{"content": "test"}}, + {http.MethodGet, "/comments/abc", nil}, + {http.MethodPatch, "/comments/abc", map[string]any{"content": "updated"}}, + {http.MethodDelete, "/comments/abc", nil}, + } { + t.Run(tc.method+tc.path, func(t *testing.T) { + assertCommentStatus(t, f.request(t, f.admin, tc.method, tc.path, tc.body), http.StatusBadRequest) + }) + } + }) +} diff --git a/tests/v2_contract_test.go b/tests/v2_contract_test.go new file mode 100644 index 0000000..ff43926 --- /dev/null +++ b/tests/v2_contract_test.go @@ -0,0 +1,165 @@ +package tests + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "windshift/internal/wscli" +) + +type v2FixtureRecord struct { + ID int `json:"id"` + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + WorkspaceID int `json:"workspace_id"` +} + +func TestV2EnvelopeRejectsLegacyAndMalformedResponses(t *testing.T) { + for _, body := range []string{`{"id":1}`, `{"data":null}`, `{"error":"failure"}`, `{"data":{}} {"data":{}}`, `{"data":{},"unexpected":1}`} { + if _, err := decodeV2Envelope([]byte(body)); err == nil { + t.Errorf("accepted %s", body) + } + } + for _, pagination := range []string{ + `{"page_size":50,"total_items":0,"total_pages":0}`, + `{"page":1,"total_items":0,"total_pages":0}`, + `{"page":1,"page_size":50,"total_pages":0}`, + `{"page":1,"page_size":50,"total_items":0}`, + `{"page":null,"page_size":50,"total_items":0,"total_pages":0}`, + `{"page":1,"page_size":null,"total_items":0,"total_pages":0}`, + `{"page":1,"page_size":50,"total_items":null,"total_pages":0}`, + `{"page":1,"page_size":50,"total_items":0,"total_pages":null}`, + } { + body := `{"data":[],"pagination":` + pagination + `}` + if _, err := decodeV2Envelope([]byte(body)); err == nil { + t.Errorf("accepted incomplete pagination %s", body) + } + } + if _, err := decodeV2Envelope([]byte(`{"data":[],"pagination":{"page":1,"page_size":50,"total_items":0,"total_pages":0},"meta":{}}`)); err != nil { + t.Fatal(err) + } +} + +// Real HTTP fixtures verify both mounts, merge-patch and the page contract. +// The shared server disables CSRF; this test does not claim CSRF coverage. +func TestV2WorkspaceItemLifecycleAndAuthenticationBoundaries(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + workspace := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodPost, "/workspaces", map[string]any{"name": "V2 contract", "key": "V2CONTRACT"}), http.StatusCreated) + if workspace.ID <= 0 || workspace.Key != "V2CONTRACT" { + t.Fatalf("workspace=%+v", workspace) + } + for _, title := range []string{"First", "Second"} { + item := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, server, http.MethodPost, "/items", map[string]any{"workspace_id": workspace.ID, "title": title, "description": "Keep this description"}), http.StatusCreated) + if item.ID <= 0 || item.Title != title || item.WorkspaceID != workspace.ID { + t.Fatalf("item=%+v", item) + } + } + query := fmt.Sprintf("/items?workspace_id=%d&page_size=1", workspace.ID) + first, p := DecodeV2Page[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodGet, query+"&page=1", nil)) + if len(first) != 1 || p.Page != 1 || p.TotalItems != 2 || p.TotalPages != 2 { + t.Fatalf("first=%+v pagination=%+v", first, p) + } + second, p := DecodeV2Page[v2FixtureRecord](t, MakeV2BearerRequest(t, server, http.MethodGet, query+"&page=2", nil)) + if len(second) != 1 || p.Page != 2 || second[0].ID == first[0].ID { + t.Fatalf("second=%+v pagination=%+v", second, p) + } + path := fmt.Sprintf("/items/%d", first[0].ID) + updated := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, server, http.MethodPatch, path, map[string]any{"title": "Changed"}), http.StatusOK) + if updated.Title != "Changed" || updated.ID != first[0].ID { + t.Fatalf("updated=%+v", updated) + } + stored := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodGet, path, nil), http.StatusOK) + if stored.Title != "Changed" || stored.ID != first[0].ID || stored.WorkspaceID != workspace.ID || stored.Description != "Keep this description" { + t.Fatalf("stored=%+v", stored) + } + response := MakeV2BearerRequest(t, server, http.MethodPatch, path, map[string]any{"title": "Must not be stored", "unknown_field": true}) + AssertStatusCode(t, response, http.StatusBadRequest) + response.Body.Close() + afterRejection := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, server, http.MethodGet, path, nil), http.StatusOK) + if afterRejection != stored { + t.Fatalf("rejected patch partially changed item: before=%+v after=%+v", stored, afterRejection) + } + // Exercise CLI wire semantics against real validation and persistence, not + // only a permissive stub: omission preserves the parent; null clears it. + type parentRecord struct { + ParentID *int `json:"parent_id"` + } + type itemType struct { + ID int `json:"id"` + HierarchyLevel int `json:"hierarchy_level"` + } + types := DecodeV2Document[[]itemType](t, MakeV2SessionRequest(t, server, http.MethodGet, "/item-types", nil), http.StatusOK) + childTypeID := 0 + for _, typ := range types { + if typ.HierarchyLevel == 1 { + childTypeID = typ.ID + break + } + } + if childTypeID == 0 { + t.Fatal("production defaults missing a level-one child type") + } + child := DecodeV2Document[v2FixtureRecord](t, MakeV2BearerRequest(t, server, http.MethodPost, "/items", map[string]any{"workspace_id": workspace.ID, "title": "CLI child", "item_type_id": childTypeID, "parent_id": second[0].ID}), http.StatusCreated) + childPath := fmt.Sprintf("/items/%d", child.ID) + config := filepath.Join(t.TempDir(), "ws.toml") + if err := os.WriteFile(config, nil, 0600); err != nil { + t.Fatal(err) + } + for _, clear := range []bool{false, true} { + args := []string{"--config", config, "task", "edit", fmt.Sprint(child.ID), "--title", "CLI changed"} + if clear { + args = append(args, "--parent", "0") + } + var out, errOut bytes.Buffer + code := wscli.Run(context.Background(), args, nil, &out, &errOut, map[string]string{"WS_URL": server.BaseURL, "WS_TOKEN": server.BearerToken, "WS_WORKSPACE": ""}) + if code != 0 { + t.Fatalf("CLI clear=%t: %s", clear, &errOut) + } + got := DecodeV2Document[parentRecord](t, MakeV2SessionRequest(t, server, http.MethodGet, childPath, nil), http.StatusOK) + if clear && got.ParentID != nil || !clear && (got.ParentID == nil || *got.ParentID != second[0].ID) { + t.Fatalf("clear=%t: persisted parent=%v", clear, got.ParentID) + } + } + for _, tc := range []struct{ name, mount, token, cookie string }{ + {"anonymous session", "/api/v2", "", ""}, + {"anonymous bearer", "/rest/api/v2", "", ""}, + {"bearer on session", "/api/v2", server.BearerToken, ""}, + {"session on bearer", "/rest/api/v2", "", server.SessionCookie}, + } { + t.Run(tc.name, func(t *testing.T) { + response := makeRequest(t, http.MethodGet, server.BaseURL+tc.mount+path, tc.token, nil, map[string]string{"Cookie": tc.cookie}) + defer response.Body.Close() + AssertStatusCode(t, response, http.StatusUnauthorized) + }) + } +} + +// Independent wire checks complement the inventory-copy unit test. Current +// inventory has Both and Session exposure; there is no Bearer-only route yet. +func TestV2ActualMountExposure(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + for _, tc := range []struct { + path string + want int + }{ + {"/api/v2/items", http.StatusUnauthorized}, + {"/rest/api/v2/items", http.StatusUnauthorized}, + {"/api/v2/admin/groups", http.StatusUnauthorized}, + {"/rest/api/v2/admin/groups", http.StatusNotFound}, + {"/api/v2/nonexistent-exposure-probe", http.StatusNotFound}, + {"/rest/api/v2/nonexistent-exposure-probe", http.StatusNotFound}, + } { + t.Run(tc.path, func(t *testing.T) { + response := makeRequest(t, http.MethodGet, server.BaseURL+tc.path, "", nil, nil) + defer response.Body.Close() + AssertStatusCode(t, response, tc.want) + }) + } +} diff --git a/tests/v2_diagram_fixtures_test.go b/tests/v2_diagram_fixtures_test.go new file mode 100644 index 0000000..f736f81 --- /dev/null +++ b/tests/v2_diagram_fixtures_test.go @@ -0,0 +1,84 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" +) + +type diagramFixture struct { + admin, editor, viewer, outsider *TestServer + workspace, otherWorkspace, item int + editorID int + bearer bool +} + +func runDiagramCase(t *testing.T, test func(*testing.T, *diagramFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + f := &diagramFixture{admin: admin, bearer: bearer} + f.workspace, _ = CreateTestWorkspace(t, admin, "Diagrams", "DGM") + f.otherWorkspace, _ = CreateTestWorkspace(t, admin, "Other diagrams", "ODGM") + actor := func(name, role string) (*TestServer, int) { + id, username, password := CreateTestUserWithCredentials(t, admin, name, name+"@example.test") + if role != "" { + AssignWorkspaceRole(t, admin, id, f.workspace, role) + } + cookie, token := CreateAuthCredentialsForUser(t, admin, username, password) + caller := *admin + caller.SessionCookie, caller.BearerToken = cookie, token + return &caller, id + } + f.editor, f.editorID = actor("diagram-editor", "Editor") + // An explicit Viewer role prevents the implicit everyone-role fallback. + f.viewer, _ = actor("diagram-viewer", "Viewer") + f.outsider, _ = actor("diagram-outsider", "") + f.item = DecodeV2Document[v2FixtureRecord](t, f.request(t, admin, http.MethodPost, "/items", map[string]any{ + "workspace_id": f.workspace, "title": "Diagram item", + }), http.StatusCreated).ID + if f.item <= 0 { + t.Fatal("diagram fixture requires a positive item ID") + } + test(t, f) + }) + } +} + +func (f *diagramFixture) request(t *testing.T, actor *TestServer, method, path string, body any) *http.Response { + t.Helper() + if f.bearer { + return MakeV2BearerRequest(t, actor, method, path, body) + } + return MakeV2SessionRequest(t, actor, method, path, body) +} + +func (f *diagramFixture) itemPath() string { return fmt.Sprintf("/items/%d/diagrams", f.item) } +func itemDiagramPath(id int) string { return fmt.Sprintf("/item-diagrams/%d", id) } + +const emptyItemDiagram = `{"nodes":[],"edges":[]}` + +func pageDiagramCreateBody(hash string) map[string]any { + return map[string]any{"name": "Flow", "mermaid": "graph TD; A-->B", "placement": "end", "expected_content_hash": hash} +} + +func (f *diagramFixture) createItemDiagram(t *testing.T) models.ItemDiagram { + t.Helper() + diagram := DecodeV2Document[models.ItemDiagram](t, f.request(t, f.editor, http.MethodPost, f.itemPath(), map[string]any{ + "name": "Test Diagram", "diagram_data": emptyItemDiagram, + }), http.StatusCreated) + if diagram.ID <= 0 || diagram.ItemID != f.item || diagram.Name != "Test Diagram" || diagram.DiagramData != emptyItemDiagram || diagram.CreatedBy == nil || *diagram.CreatedBy != f.editorID { + t.Fatal("created item diagram lost its identity, content or author") + } + return diagram +} + +func assertDiagramStatus(t *testing.T, response *http.Response, status int) { + t.Helper() + // This shared assertion also enforces an empty body for 204 responses. + assertCommentStatus(t, response, status) +} diff --git a/tests/v2_diagram_scopes_test.go b/tests/v2_diagram_scopes_test.go new file mode 100644 index 0000000..3815f3a --- /dev/null +++ b/tests/v2_diagram_scopes_test.go @@ -0,0 +1,89 @@ +package tests + +import ( + "fmt" + "net/http" + "strconv" + "testing" + + "windshift/internal/models" + "windshift/internal/services" +) + +func TestV2Diagrams_GranularBearerScopes(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + workspace, _ := CreateTestWorkspace(t, admin, "Diagram scopes", "DSCP") + item := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, admin, http.MethodPost, "/items", map[string]any{"workspace_id": workspace, "title": "Scoped diagrams"}), http.StatusCreated) + itemCollection := fmt.Sprintf("/items/%d/diagrams", item.ID) + itemBody := map[string]any{"name": "Scoped diagram", "diagram_data": emptyItemDiagram} + diagram := DecodeV2Document[models.ItemDiagram](t, MakeV2SessionRequest(t, admin, http.MethodPost, itemCollection, itemBody), http.StatusCreated) + page := DecodeV2Document[models.Page](t, MakeV2SessionRequest(t, admin, http.MethodPost, fmt.Sprintf("/workspaces/%d/pages", workspace), map[string]any{"title": "Diagram scopes"}), http.StatusCreated) + pageCollection := fmt.Sprintf("/workspaces/%d/pages/%d/diagrams", workspace, page.ID) + pageDiagram := DecodeV2Document[services.PageDiagram](t, MakeV2SessionRequest(t, admin, http.MethodPost, pageCollection, pageDiagramCreateBody(page.ContentHash)), http.StatusCreated) + if item.ID <= 0 || diagram.ID <= 0 || page.ID <= 0 || pageDiagram.AttachmentID <= 0 { + t.Fatal("scope fixtures require positive resource IDs") + } + pageDetail := pageCollection + "/" + strconv.Itoa(pageDiagram.AttachmentID) + caller := func(scope string) *TestServer { + t.Helper() + actor := *admin + actor.SessionCookie = "" + actor.BearerToken = createTokenWithScopesAsUser(t, admin, "admin", "testpass123", []string{scope}) + return &actor + } + t.Run("missing_scope", func(t *testing.T) { + actor := caller("users:read") + for _, path := range []string{itemCollection, itemDiagramPath(diagram.ID), pageCollection, pageDetail} { + assertDiagramStatus(t, MakeV2BearerRequest(t, actor, http.MethodGet, path, nil), http.StatusForbidden) + } + }) + t.Run("read_only", func(t *testing.T) { + for _, tc := range []struct { + scope, collection, detail, idKey string + id int + create, patch any + }{ + {"items:read", itemCollection, itemDiagramPath(diagram.ID), "id", diagram.ID, itemBody, map[string]any{"name": "Denied"}}, + {"pages:read", pageCollection, pageDetail, "attachment_id", pageDiagram.AttachmentID, pageDiagramCreateBody(pageDiagram.ContentHash), map[string]any{"name": "Denied", "mermaid": "graph TD; B-->C"}}, + } { + t.Run(tc.scope, func(t *testing.T) { + actor := caller(tc.scope) + listed := DecodeV2Document[[]map[string]any](t, MakeV2BearerRequest(t, actor, http.MethodGet, tc.collection, nil), http.StatusOK) + got := DecodeV2Document[map[string]any](t, MakeV2BearerRequest(t, actor, http.MethodGet, tc.detail, nil), http.StatusOK) + if len(listed) != 1 || listed[0][tc.idKey] != float64(tc.id) || got[tc.idKey] != float64(tc.id) { + t.Fatal("read scope did not return the expected diagram") + } + assertDiagramStatus(t, MakeV2BearerRequest(t, actor, http.MethodPost, tc.collection, tc.create), http.StatusForbidden) + assertDiagramStatus(t, MakeV2BearerRequest(t, actor, http.MethodPatch, tc.detail, tc.patch), http.StatusForbidden) + }) + } + assertDiagramStatus(t, MakeV2BearerRequest(t, caller("items:read"), http.MethodDelete, itemDiagramPath(diagram.ID), nil), http.StatusForbidden) + items := DecodeV2Document[[]models.ItemDiagram](t, MakeV2SessionRequest(t, admin, http.MethodGet, itemCollection, nil), http.StatusOK) + pages := DecodeV2Document[[]services.PageDiagram](t, MakeV2SessionRequest(t, admin, http.MethodGet, pageCollection, nil), http.StatusOK) + if len(items) != 1 || items[0].ID != diagram.ID || items[0].Name != diagram.Name || len(pages) != 1 || pages[0].AttachmentID != pageDiagram.AttachmentID || pages[0].Name != pageDiagram.Name { + t.Fatal("scope-denied writes changed stored diagrams") + } + }) + t.Run("write_implies_read", func(t *testing.T) { + actor := caller("items:write") + created := DecodeV2Document[models.ItemDiagram](t, MakeV2BearerRequest(t, actor, http.MethodPost, itemCollection, itemBody), http.StatusCreated) + path := itemDiagramPath(created.ID) + updated := DecodeV2Document[models.ItemDiagram](t, MakeV2BearerRequest(t, actor, http.MethodPatch, path, map[string]any{"name": "Written"}), http.StatusOK) + stored := DecodeV2Document[models.ItemDiagram](t, MakeV2BearerRequest(t, actor, http.MethodGet, path, nil), http.StatusOK) + if created.ID <= 0 || updated.Name != "Written" || stored.ID != created.ID || stored.Name != updated.Name { + t.Fatal("items:write did not grant persisted CRUD") + } + // Item diagram deletion deliberately requires items:write, not items:delete. + assertDiagramStatus(t, MakeV2BearerRequest(t, actor, http.MethodDelete, path, nil), http.StatusNoContent) + assertDiagramStatus(t, MakeV2SessionRequest(t, admin, http.MethodGet, path, nil), http.StatusNotFound) + actor = caller("pages:write") + written := DecodeV2Document[services.PageDiagram](t, MakeV2BearerRequest(t, actor, http.MethodPost, pageCollection, pageDiagramCreateBody(pageDiagram.ContentHash)), http.StatusCreated) + path = pageCollection + "/" + strconv.Itoa(written.AttachmentID) + changed := DecodeV2Document[services.PageDiagram](t, MakeV2BearerRequest(t, actor, http.MethodPatch, path, map[string]any{"name": "Written", "mermaid": "graph TD; B-->C", "expected_content_hash": written.ContentHash}), http.StatusOK) + storedPage := DecodeV2Document[services.PageDiagram](t, MakeV2BearerRequest(t, actor, http.MethodGet, pageCollection+"/"+strconv.Itoa(changed.AttachmentID), nil), http.StatusOK) + if written.AttachmentID <= 0 || changed.AttachmentID == written.AttachmentID || storedPage.AttachmentID != changed.AttachmentID || storedPage.Name != "Written" { + t.Fatal("pages:write did not grant persisted create/update/read") + } + }) +} diff --git a/tests/v2_helpers.go b/tests/v2_helpers.go new file mode 100644 index 0000000..8e3b81d --- /dev/null +++ b/tests/v2_helpers.go @@ -0,0 +1,140 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// These helpers address v2 explicitly. Bootstrap, authentication, portal and +// other retained legacy routes continue to use the existing helpers. +func MakeV2SessionRequest(t *testing.T, server *TestServer, method, endpoint string, body any) *http.Response { + t.Helper() + return makeSessionRequest(t, method, v2URL(t, server, "/api/v2", endpoint), server.SessionCookie, body, v2Headers(method)) +} + +func MakeV2BearerRequest(t *testing.T, server *TestServer, method, endpoint string, body any) *http.Response { + t.Helper() + return makeRequest(t, method, v2URL(t, server, "/rest/api/v2", endpoint), server.BearerToken, body, v2Headers(method)) +} + +func v2URL(t *testing.T, server *TestServer, prefix, endpoint string) string { + t.Helper() + if !strings.HasPrefix(endpoint, "/") || strings.HasPrefix(endpoint, "//") { + t.Fatalf("v2 endpoint must be an absolute path within its mount: %q", endpoint) + } + return server.BaseURL + prefix + endpoint +} + +func v2Headers(method string) map[string]string { + if method == http.MethodPatch { + return map[string]string{"Content-Type": "application/merge-patch+json"} + } + return nil +} + +type V2Pagination struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalItems int `json:"total_items"` + TotalPages int `json:"total_pages"` +} + +func (p *V2Pagination) UnmarshalJSON(body []byte) error { + var fields struct { + Page *int `json:"page"` + PageSize *int `json:"page_size"` + TotalItems *int `json:"total_items"` + TotalPages *int `json:"total_pages"` + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&fields); err != nil { + return err + } + if fields.Page == nil || fields.PageSize == nil || fields.TotalItems == nil || fields.TotalPages == nil { + return fmt.Errorf("all four v2 pagination fields must be present and non-null") + } + *p = V2Pagination{Page: *fields.Page, PageSize: *fields.PageSize, TotalItems: *fields.TotalItems, TotalPages: *fields.TotalPages} + return nil +} + +type v2Envelope struct { + Data json.RawMessage `json:"data"` + Pagination *V2Pagination `json:"pagination,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +func decodeV2Envelope(body []byte) (v2Envelope, error) { + var result v2Envelope + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&result); err != nil { + return result, err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return result, fmt.Errorf("expected a single JSON document") + } + if len(result.Data) == 0 || bytes.Equal(result.Data, []byte("null")) { + return result, fmt.Errorf("v2 response is missing non-null data") + } + return result, nil +} + +func readV2Envelope(t *testing.T, response *http.Response, status int) v2Envelope { + t.Helper() + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != status { + t.Fatalf("expected HTTP %d, got %d: %s", status, response.StatusCode, body) + } + result, err := decodeV2Envelope(body) + if err != nil { + t.Fatalf("invalid v2 envelope: %v; body: %s", err, body) + } + return result +} + +func DecodeV2Document[T any](t *testing.T, response *http.Response, status int) T { + t.Helper() + envelope := readV2Envelope(t, response, status) + if envelope.Pagination != nil { + t.Fatal("expected a document, got a page") + } + var result T + if err := json.Unmarshal(envelope.Data, &result); err != nil { + t.Fatal(err) + } + return result +} + +func DecodeV2Page[T any](t *testing.T, response *http.Response) ([]T, V2Pagination) { + t.Helper() + envelope := readV2Envelope(t, response, http.StatusOK) + p := envelope.Pagination + if p == nil || p.Page < 1 || p.PageSize < 1 || p.TotalItems < 0 || p.TotalPages < 0 { + t.Fatal("missing or invalid v2 pagination") + } + wantPages := p.TotalItems / p.PageSize + if p.TotalItems%p.PageSize != 0 { + wantPages++ + } + if p.TotalPages != wantPages { + t.Fatalf("pagination total_pages=%d, want %d", p.TotalPages, wantPages) + } + var result []T + if err := json.Unmarshal(envelope.Data, &result); err != nil { + t.Fatal(err) + } + if len(result) > p.PageSize { + t.Fatal("page exceeds page_size") + } + return result, *p +} diff --git a/tests/v2_item_diagrams_test.go b/tests/v2_item_diagrams_test.go new file mode 100644 index 0000000..0f72b08 --- /dev/null +++ b/tests/v2_item_diagrams_test.go @@ -0,0 +1,138 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" +) + +// These real-HTTP cases replace the 13 removed cookie DiagramHandler tests. +// V1 REST routes are separate and their existing coverage is not removed. +func TestV2ItemDiagrams_CRUDAndWorkspacePermissions(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + created := f.createItemDiagram(t) // Editor create permission, not admin bypass. + path := itemDiagramPath(created.ID) + for _, actor := range []*TestServer{f.editor, f.viewer} { + listed := DecodeV2Document[[]models.ItemDiagram](t, f.request(t, actor, http.MethodGet, f.itemPath(), nil), http.StatusOK) + if len(listed) != 1 || listed[0].ID != created.ID || listed[0].ItemID != f.item { + t.Fatal("authorized list did not return exactly the created diagram") + } + got := DecodeV2Document[models.ItemDiagram](t, f.request(t, actor, http.MethodGet, path, nil), http.StatusOK) + if got.ID != created.ID || got.Name != created.Name || got.DiagramData != emptyItemDiagram { + t.Fatal("authorized GET did not return the stored diagram") + } + } + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertDiagramStatus(t, f.request(t, actor, http.MethodPost, f.itemPath(), map[string]any{"name": "Denied", "diagram_data": emptyItemDiagram}), http.StatusNotFound) + assertDiagramStatus(t, f.request(t, actor, http.MethodPatch, path, map[string]any{"name": "Denied"}), http.StatusNotFound) + assertDiagramStatus(t, f.request(t, actor, http.MethodDelete, path, nil), http.StatusNotFound) + } + assertDiagramStatus(t, f.request(t, f.outsider, http.MethodGet, f.itemPath(), nil), http.StatusNotFound) + assertDiagramStatus(t, f.request(t, f.outsider, http.MethodGet, path, nil), http.StatusNotFound) + retained := DecodeV2Document[[]models.ItemDiagram](t, f.request(t, f.editor, http.MethodGet, f.itemPath(), nil), http.StatusOK) + if len(retained) != 1 || retained[0].ID != created.ID || retained[0].Name != created.Name || retained[0].DiagramData != emptyItemDiagram { + t.Fatal("denied writes changed the stored diagram collection") + } + updated := DecodeV2Document[models.ItemDiagram](t, f.request(t, f.editor, http.MethodPatch, path, map[string]any{"name": "Renamed"}), http.StatusOK) + if updated.ID != created.ID || updated.Name != "Renamed" || updated.DiagramData != emptyItemDiagram || updated.UpdatedBy == nil || *updated.UpdatedBy != f.editorID { + t.Fatal("name-only PATCH lost data or update attribution") + } + // Read through the other mount to prove the PATCH was persisted. + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.ItemDiagram](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.ID != updated.ID || stored.Name != updated.Name || stored.DiagramData != emptyItemDiagram { + t.Fatal("PATCH did not persist across mounts") + } + assertDiagramStatus(t, f.request(t, f.editor, http.MethodDelete, path, nil), http.StatusNoContent) + assertDiagramStatus(t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusNotFound) + if diagrams := DecodeV2Document[[]models.ItemDiagram](t, f.request(t, f.editor, http.MethodGet, f.itemPath(), nil), http.StatusOK); len(diagrams) != 0 { + t.Fatal("deleted diagram remains in the item collection") + } + }) +} + +func TestV2ItemDiagrams_Unauthenticated(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + diagram := f.createItemDiagram(t) + anonymous := *f.admin + anonymous.SessionCookie, anonymous.BearerToken = "", "" + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodPost, f.itemPath(), map[string]any{"name": "Unauthorized", "diagram_data": emptyItemDiagram}}, + {http.MethodGet, f.itemPath(), nil}, + {http.MethodGet, itemDiagramPath(diagram.ID), nil}, + {http.MethodPatch, itemDiagramPath(diagram.ID), map[string]any{"name": "Unauthorized"}}, + {http.MethodDelete, itemDiagramPath(diagram.ID), nil}, + } { + t.Run(tc.method+tc.path, func(t *testing.T) { + assertDiagramStatus(t, f.request(t, &anonymous, tc.method, tc.path, tc.body), http.StatusUnauthorized) + }) + } + retained := DecodeV2Document[[]models.ItemDiagram](t, f.request(t, f.editor, http.MethodGet, f.itemPath(), nil), http.StatusOK) + if len(retained) != 1 || retained[0].ID != diagram.ID || retained[0].Name != diagram.Name { + t.Fatal("anonymous writes changed persisted diagrams") + } + }) +} + +func TestV2ItemDiagrams_NotFound(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + assertDiagramStatus(t, f.request(t, f.admin, http.MethodPost, "/items/999999/diagrams", map[string]any{"name": "Missing item", "diagram_data": emptyItemDiagram}), http.StatusNotFound) + for _, method := range []string{http.MethodGet, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + var body any + if method == http.MethodPatch { + body = map[string]any{"name": "Missing diagram"} + } + assertDiagramStatus(t, f.request(t, f.admin, method, itemDiagramPath(999999), body), http.StatusNotFound) + }) + } + }) +} + +func TestV2ItemDiagrams_MergePatchValidation(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + diagram := f.createItemDiagram(t) + path := itemDiagramPath(diagram.ID) + for i, tc := range []struct { + body map[string]any + message string + }{ + {map[string]any{"name": nil}, "Diagram fields cannot be null"}, + {map[string]any{"diagram_data": nil}, "Diagram fields cannot be null"}, + {map[string]any{"name": ""}, "diagram name is required"}, + {map[string]any{"diagram_data": ""}, "diagram data is required"}, + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + assertCommentStatus(t, f.request(t, f.editor, http.MethodPatch, path, tc.body), http.StatusBadRequest, tc.message) + }) + } + stored := DecodeV2Document[models.ItemDiagram](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != diagram.Name || stored.DiagramData != diagram.DiagramData { + t.Fatal("invalid PATCH changed diagram content") + } + const data = `{"nodes":[{"id":"one"}],"edges":[]}` + updated := DecodeV2Document[models.ItemDiagram](t, f.request(t, f.editor, http.MethodPatch, path, map[string]any{"diagram_data": data}), http.StatusOK) + if updated.Name != diagram.Name || updated.DiagramData != data { + t.Fatal("data-only PATCH lost the name or ignored the data") + } + stored = DecodeV2Document[models.ItemDiagram](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != diagram.Name || stored.DiagramData != data { + t.Fatal("data-only PATCH was not persisted") + } + // Preserve the old update scenario changing both fields in one request. + updated = DecodeV2Document[models.ItemDiagram](t, f.request(t, f.editor, http.MethodPatch, path, map[string]any{"name": "Both changed", "diagram_data": emptyItemDiagram}), http.StatusOK) + if updated.ID != diagram.ID || updated.Name != "Both changed" || updated.DiagramData != emptyItemDiagram { + t.Fatal("combined PATCH did not replace both fields") + } + stored = DecodeV2Document[models.ItemDiagram](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != updated.Name || stored.DiagramData != emptyItemDiagram { + t.Fatal("combined PATCH was not persisted") + } + }) +} diff --git a/tests/v2_item_links_test.go b/tests/v2_item_links_test.go new file mode 100644 index 0000000..6a46a3b --- /dev/null +++ b/tests/v2_item_links_test.go @@ -0,0 +1,139 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" + "windshift/internal/services" +) + +func (f *itemRelationFixture) linkType(t *testing.T) int { + t.Helper() + kind := DecodeV2Document[models.LinkType](t, f.request(t, f.admin, http.MethodPost, "/link-types", map[string]any{ + "name": "Relation blocks", "forward_label": "blocks", "reverse_label": "is blocked by", + }), http.StatusCreated) + if kind.ID <= 0 || !kind.Active || kind.IsSystem { + t.Fatal("link-type fixture needs a positive active custom type") + } + return kind.ID +} + +func relationBody(source, target, kind int) map[string]any { + return map[string]any{"source_type": "item", "source_id": source, "target_type": "item", "target_id": target, "link_type_id": kind} +} + +func (f *itemRelationFixture) itemLinks(t *testing.T, actor *TestServer, item int) services.EntityLinks { + t.Helper() + return DecodeV2Document[services.EntityLinks](t, f.request(t, actor, http.MethodGet, fmt.Sprintf("/items/%d/links", item), nil), http.StatusOK) +} + +// These v2 cases preserve all seven old cookie ItemLinkHandler scenarios. +func TestV2ItemLinks_SourceEditAndTargetView(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + kind, target := f.linkType(t), f.createItem(t, f.foreign, "Foreign target") + body := relationBody(f.item, target, kind) + empty := f.itemLinks(t, f.viewer, f.item) + if len(empty.Outgoing) != 0 || len(empty.Incoming) != 0 { + t.Fatal("new item has unexpected links") + } + // Source Editor alone is insufficient when the target is invisible. + assertCommentStatus(t, f.request(t, f.editor, http.MethodGet, fmt.Sprintf("/items/%d", target), nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.editor, http.MethodPost, "/links", body), http.StatusNotFound) + if got := f.itemLinks(t, f.admin, f.item); len(got.Outgoing) != 0 { + t.Fatal("invisible-target link was persisted") + } + AssignWorkspaceRole(t, f.admin, f.editorID, f.foreign, "Viewer") + visible := DecodeV2Document[v2FixtureRecord](t, f.request(t, f.editor, http.MethodGet, fmt.Sprintf("/items/%d", target), nil), http.StatusOK) + if visible.ID != target { + t.Fatal("target Viewer grant did not expose the intended item") + } + created := DecodeV2Document[models.ItemLink](t, f.request(t, f.editor, http.MethodPost, "/links", body), http.StatusCreated) + if created.ID <= 0 || created.SourceType != "item" || created.SourceID != f.item || created.TargetType != "item" || created.TargetID != target || created.LinkTypeID != kind { + t.Fatal("created link lost identity or endpoints") + } + other := *f + other.bearer = !f.bearer + got, incoming := other.itemLinks(t, f.editor, f.item), other.itemLinks(t, f.editor, target) + if len(got.Outgoing) != 1 || got.Outgoing[0].ID != created.ID || len(incoming.Incoming) != 1 || incoming.Incoming[0].ID != created.ID { + t.Fatal("link did not persist on both endpoints across mounts") + } + assertCommentStatus(t, f.request(t, f.editor, http.MethodPost, "/links", body), http.StatusConflict) + path := fmt.Sprintf("/links/%d", created.ID) + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertCommentStatus(t, f.request(t, actor, http.MethodDelete, path, nil), http.StatusNotFound) + } + if got := f.itemLinks(t, f.editor, f.item); len(got.Outgoing) != 1 || got.Outgoing[0].ID != created.ID { + t.Fatal("denied deletion changed stored link") + } + // A readable source must not expose a target the Viewer cannot see. + if hidden := f.itemLinks(t, f.viewer, f.item); len(hidden.Outgoing) != 0 || len(hidden.Incoming) != 0 { + t.Fatal("link list exposed an inaccessible endpoint") + } + assertCommentStatus(t, f.request(t, f.outsider, http.MethodGet, fmt.Sprintf("/items/%d/links", f.item), nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.editor, http.MethodDelete, path, nil), http.StatusNoContent) + if source, target := other.itemLinks(t, f.editor, f.item), other.itemLinks(t, f.editor, target); len(source.Outgoing) != 0 || len(target.Incoming) != 0 { + t.Fatal("link deletion did not persist on both endpoints") + } + }) +} + +func TestV2ItemLinks_RequiresSourceEdit(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + kind, target := f.linkType(t), f.createItem(t, f.workspace, "Readable target") + body := relationBody(f.item, target, kind) + // Viewer sees BOTH endpoints, but may not edit the source. + f.itemLinks(t, f.viewer, f.item) + f.itemLinks(t, f.viewer, target) + assertCommentStatus(t, f.request(t, f.viewer, http.MethodPost, "/links", body), http.StatusNotFound) + if got := f.itemLinks(t, f.editor, f.item); len(got.Outgoing) != 0 { + t.Fatal("Viewer create persisted a link") + } + created := DecodeV2Document[models.ItemLink](t, f.request(t, f.editor, http.MethodPost, "/links", body), http.StatusCreated) + got := f.itemLinks(t, f.viewer, f.item) + if created.ID <= 0 || len(got.Outgoing) != 1 || got.Outgoing[0].ID != created.ID { + t.Fatal("Editor create was not visible to the Viewer") + } + }) +} + +func TestV2ItemLinks_MissingSourceAndItem(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + kind := f.linkType(t) + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, "/items/999999/links", nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/links", relationBody(999999, f.item, kind)), http.StatusNotFound) + if got := f.itemLinks(t, f.admin, f.item); len(got.Incoming) != 0 || len(got.Outgoing) != 0 { + t.Fatal("missing-source create changed links") + } + }) +} + +func TestV2ItemLinks_Unauthenticated(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + kind, target := f.linkType(t), f.createItem(t, f.workspace, "Target") + body := relationBody(f.item, target, kind) + created := DecodeV2Document[models.ItemLink](t, f.request(t, f.editor, http.MethodPost, "/links", body), http.StatusCreated) + if created.ID <= 0 { + t.Fatal("anonymous-delete fixture needs a positive link ID") + } + anonymous := *f.admin + anonymous.SessionCookie, anonymous.BearerToken = "", "" + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, fmt.Sprintf("/items/%d/links", f.item), nil}, + {http.MethodPost, "/links", relationBody(target, f.item, kind)}, + {http.MethodDelete, fmt.Sprintf("/links/%d", created.ID), nil}, + } { + t.Run(tc.method, func(t *testing.T) { + assertCommentStatus(t, f.request(t, &anonymous, tc.method, tc.path, tc.body), http.StatusUnauthorized) + }) + } + got := f.itemLinks(t, f.editor, f.item) + if len(got.Outgoing) != 1 || got.Outgoing[0].ID != created.ID || len(got.Incoming) != 0 { + t.Fatal("anonymous mutation changed links") + } + }) +} diff --git a/tests/v2_item_relation_fixtures_test.go b/tests/v2_item_relation_fixtures_test.go new file mode 100644 index 0000000..1030522 --- /dev/null +++ b/tests/v2_item_relation_fixtures_test.go @@ -0,0 +1,72 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +// Labels and links share workspace-role fixtures, not database shortcuts. +type itemRelationFixture struct { + admin, editor, viewer, outsider *TestServer + workspace, foreign, item int + editorID int + bearer bool +} + +func runItemRelationCase(t *testing.T, test func(*testing.T, *itemRelationFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + f := &itemRelationFixture{admin: admin, bearer: bearer} + f.workspace, _ = CreateTestWorkspace(t, admin, "Item relations", "REL") + LockDownWorkspace(t, admin, f.workspace) + f.foreign, _ = CreateTestWorkspace(t, admin, "Foreign relations", "FREL") + // Without explicit membership, the everyone-role fallback would make + // this supposed hidden target visible and invalidate denial tests. + LockDownWorkspace(t, admin, f.foreign) + actor := func(name, role string) *TestServer { + id, username, password := CreateTestUserWithCredentials(t, admin, name, name+"@example.test") + if role == "Editor" { + f.editorID = id + } + if role != "" { + AssignWorkspaceRole(t, admin, id, f.workspace, role) + } + cookie, token := CreateAuthCredentialsForUser(t, admin, username, password) + caller := *admin + caller.SessionCookie, caller.BearerToken = cookie, token + return &caller + } + f.editor, f.viewer, f.outsider = actor("relation-editor", "Editor"), actor("relation-viewer", "Viewer"), actor("relation-outsider", "") + f.item = f.createItem(t, f.workspace, "Relation source") + test(t, f) + }) + } +} + +func (f *itemRelationFixture) request(t *testing.T, actor *TestServer, method, path string, body any) *http.Response { + t.Helper() + var response *http.Response + if f.bearer { + response = MakeV2BearerRequest(t, actor, method, path, body) + } else { + response = MakeV2SessionRequest(t, actor, method, path, body) + } + // Preserve legacy JSON Content-Type assertions as well as v2 envelopes. + if response.StatusCode != http.StatusNoContent { + assertCommentJSON(t, response) + } + return response +} + +func (f *itemRelationFixture) createItem(t *testing.T, workspace int, title string) int { + t.Helper() + item := DecodeV2Document[v2FixtureRecord](t, f.request(t, f.admin, http.MethodPost, "/items", map[string]any{"workspace_id": workspace, "title": title}), http.StatusCreated) + if item.ID <= 0 { + t.Fatal("relation fixture needs a positive item ID") + } + return item.ID +} diff --git a/tests/v2_iterations_test.go b/tests/v2_iterations_test.go new file mode 100644 index 0000000..3a9ac64 --- /dev/null +++ b/tests/v2_iterations_test.go @@ -0,0 +1,371 @@ +package tests + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "windshift/internal/models" + "windshift/internal/services" +) + +func iterationPath(id int) string { return fmt.Sprintf("/iterations/%d", id) } +func workspaceIterations(workspace int) string { + return fmt.Sprintf("/workspaces/%d/iterations", workspace) +} + +func iterationBody(name, status string) map[string]any { + return map[string]any{"name": name, "description": "Iteration description", "start_date": "2026-07-01", "end_date": "2026-07-14", "status": status} +} + +func (f *catalogFixture) iteration(t *testing.T, collection, name, status string) models.Iteration { + t.Helper() + created := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPost, collection, iterationBody(name, status)), http.StatusCreated) + wantStatus := status + if wantStatus == "" { + wantStatus = "planned" + } + if created.ID <= 0 || created.Name != name || created.Status != wantStatus || created.StartDate != "2026-07-01" || created.EndDate != "2026-07-14" { + t.Fatal("iteration fixture lost identity, dates or status") + } + return created +} + +// Replaces 15 removed cookie IterationHandler scenarios, plus the separate +// omitted/empty/null regression. V2 uses path-selected scope, local-only +// workspace lists and rejects invalid nonempty create statuses. Those are +// explicit contract changes, not preserved legacy expectations. +func TestV2Iterations_CRUDAndProgress(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + workspace, _ := CreateTestWorkspace(t, f.admin, "Iteration lifecycle", "ILC") + for _, collection := range []string{"/iterations", workspaceIterations(workspace)} { + t.Run(collection, func(t *testing.T) { + created := f.iteration(t, collection, "Original iteration", "planned") + global := collection == "/iterations" + if created.IsGlobal != global || (global && created.WorkspaceID != nil) || (!global && (created.WorkspaceID == nil || *created.WorkspaceID != workspace)) { + t.Fatal("create did not use path-selected scope") + } + path := iterationPath(created.ID) + got := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if got.ID != created.ID || got.Name != created.Name || got.Description != created.Description { + t.Fatal("GET lost iteration identity or description") + } + updated := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"name": "Updated iteration", "description": "Updated description", "start_date": "2026-07-05", "end_date": "2026-07-20", "status": "active"}), http.StatusOK) + if updated.ID != created.ID || updated.Name != "Updated iteration" || updated.Description != "Updated description" || updated.StartDate != "2026-07-05" || updated.EndDate != "2026-07-20" || updated.Status != "active" || updated.IsGlobal != global || !reflect.DeepEqual(updated.WorkspaceID, created.WorkspaceID) { + t.Fatal("PATCH lost requested fields or changed scope") + } + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.Iteration](t, other.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if !reflect.DeepEqual(stored, updated) { + t.Fatal("iteration PATCH did not persist across mounts") + } + progress := DecodeV2Document[services.IterationProgressReport](t, f.request(t, f.admin, http.MethodGet, path+"/progress", nil), http.StatusOK) + if progress.IterationID != created.ID || progress.IterationName != updated.Name || progress.TotalItems != 0 { + t.Fatal("empty iteration progress lost identity or reported phantom items") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, path, nil), http.StatusNoContent) + assertV2Error(t, other.request(t, f.admin, http.MethodGet, path, nil), http.StatusNotFound, "not_found", "Planning object was not found") + assertCommentStatus(t, other.request(t, f.admin, http.MethodGet, path+"/progress", nil), http.StatusNotFound) + }) + } + for _, suffix := range []string{"", "/progress"} { + assertV2Error(t, f.request(t, f.admin, http.MethodGet, iterationPath(999999)+suffix, nil), http.StatusNotFound, "not_found", "Planning object was not found") + } + }) +} + +func TestV2Iterations_ListScopeAndStatus(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + workspace, _ := CreateTestWorkspace(t, f.admin, "Iteration scope", "ISC") + foreign, _ := CreateTestWorkspace(t, f.admin, "Other iteration scope", "OISC") + global := []models.Iteration{f.iteration(t, "/iterations", "Planned", "planned"), f.iteration(t, "/iterations", "Active", "active"), f.iteration(t, "/iterations", "Completed", "completed")} + local := f.iteration(t, workspaceIterations(workspace), "Local", "active") + other := f.iteration(t, workspaceIterations(foreign), "Foreign", "active") + checkList := func(path string, ids ...int) { + t.Helper() + rows, page := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, path, nil)) + if len(rows) != len(ids) || page.TotalItems != len(ids) { + t.Fatalf("%s: got %d rows and total %d; want %d", path, len(rows), page.TotalItems, len(ids)) + } + seen := map[int]bool{} + for _, row := range rows { + if seen[row.ID] { + t.Fatal("duplicate iteration in list") + } + seen[row.ID] = true + } + for _, id := range ids { + if !seen[id] { + t.Fatalf("%s omitted iteration %d", path, id) + } + } + } + checkList("/iterations", global[0].ID, global[1].ID, global[2].ID) + // Unlike the old cookie query filter, v2 workspace lists exclude globals. + checkList(workspaceIterations(workspace), local.ID) + checkList(workspaceIterations(foreign), other.ID) + checkList("/iterations?status=active", global[1].ID) + checkList(workspaceIterations(workspace)+"?status=active", local.ID) + checkList(workspaceIterations(workspace) + "?status=planned") + }) +} + +func TestV2Iterations_ValidationAndPathScope(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + workspace, _ := CreateTestWorkspace(t, f.admin, "Iteration validation", "IVL") + for _, tc := range []struct { + name, field string + value any + message string + }{ + {"missing name", "name", nil, "is required"}, {"blank name", "name", " ", "is required"}, + {"missing start", "start_date", nil, "is required"}, {"missing end", "end_date", nil, "is required"}, + {"invalid date", "start_date", "July 1", "must use YYYY-MM-DD"}, + {"reversed dates", "end_date", "2026-06-30", "must be on or after start_date"}, + {"invalid status", "status", "invalid-status", "must be planned, active, completed, or cancelled"}, + } { + t.Run(tc.name, func(t *testing.T) { + body := iterationBody("Invalid iteration", "planned") + if tc.value == nil { + delete(body, tc.field) + } else { + body[tc.field] = tc.value + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/iterations", body), http.StatusBadRequest, tc.message) + }) + } + // is_global/workspace_id are not v2 create fields. Inconsistent old body + // scopes are rejected; scope is now selected exclusively by the route. + for _, tc := range []struct { + path, field string + value any + }{ + {"/iterations", "workspace_id", workspace}, {workspaceIterations(workspace), "is_global", true}, + } { + body := iterationBody("Rejected body scope", "planned") + body[tc.field] = tc.value + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, tc.path, body), http.StatusBadRequest, "Request body is not valid JSON") + } + // A nonadmin cannot infer whether an inaccessible workspace exists. + assertCommentStatus(t, f.request(t, f.reader, http.MethodPost, workspaceIterations(999999), iterationBody("Missing workspace", "planned")), http.StatusNotFound) + rows, page := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, "/iterations", nil)) + if len(rows) != 0 || page.TotalItems != 0 { + t.Fatal("invalid creates persisted iterations") + } + local, _ := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, workspaceIterations(workspace), nil)) + if len(local) != 0 { + t.Fatal("invalid scope create persisted locally") + } + body := iterationBody("Omitted status", "planned") + delete(body, "status") + created := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPost, "/iterations", body), http.StatusCreated) + if created.ID <= 0 || created.Status != "planned" { + t.Fatal("omitted status did not default to planned") + } + path := iterationPath(created.ID) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"name": "Must not persist", "status": "invalid-status"}), http.StatusBadRequest, "must be planned, active, completed, or cancelled") + stored := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if !reflect.DeepEqual(stored, created) { + t.Fatal("invalid PATCH changed iteration") + } + }) +} + +func TestV2Iterations_PatchOmittedEmptyAndNull(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + // Iteration-type configuration retains its supported cookie API. + response := MakeAuthRequest(t, f.admin, http.MethodPost, "/iteration-types", map[string]any{"name": "Patch type", "color": "#123456"}) + AssertStatusCode(t, response, http.StatusCreated) + assertCommentJSON(t, response) + var kind models.IterationType + DecodeJSON(t, response, &kind) + response.Body.Close() + if kind.ID <= 0 { + t.Fatal("type fixture needs positive ID") + } + body := iterationBody("Original", "planned") + body["type_id"] = kind.ID + created := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPost, "/iterations", body), http.StatusCreated) + if created.ID <= 0 || created.TypeID == nil || *created.TypeID != kind.ID { + t.Fatal("iteration lost initial type") + } + path := iterationPath(created.ID) + patch := func(body map[string]any) models.Iteration { + t.Helper() + updated := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPatch, path, body), http.StatusOK) + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.Iteration](t, other.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if !reflect.DeepEqual(stored, updated) { + t.Fatal("partial PATCH did not persist") + } + return updated + } + renamed := patch(map[string]any{"name": "Renamed"}) + if renamed.Name != "Renamed" || renamed.Description != created.Description || renamed.TypeID == nil || *renamed.TypeID != kind.ID || renamed.StartDate != created.StartDate || renamed.EndDate != created.EndDate || renamed.Status != created.Status { + t.Fatal("omitted fields were not preserved") + } + cleared := patch(map[string]any{"description": ""}) + if cleared.Description != "" || cleared.TypeID == nil || *cleared.TypeID != kind.ID || cleared.Name != "Renamed" { + t.Fatal("empty description did not clear only description") + } + cleared = patch(map[string]any{"type_id": nil}) + if cleared.TypeID != nil || cleared.Name != "Renamed" || cleared.Description != "" { + t.Fatal("null type did not clear only type") + } + }) +} + +func TestV2Iterations_WorkspaceAndGlobalPermissions(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + localCollection := workspaceIterations(f.workspace) + local := DecodeV2Document[models.Iteration](t, f.request(t, f.editor, http.MethodPost, localCollection, iterationBody("Local protected", "planned")), http.StatusCreated) + global := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodPost, "/iterations", iterationBody("Global protected", "planned")), http.StatusCreated) + if local.ID <= 0 || global.ID <= 0 { + t.Fatal("permissions need existing local and global iterations") + } + for _, target := range []models.Iteration{local, global} { + path := iterationPath(target.ID) + collection := "/iterations" + if !target.IsGlobal { + collection = localCollection + } + for _, actor := range []*TestServer{f.viewer, f.outsider} { + readStatus := http.StatusOK + if actor == f.outsider && !target.IsGlobal { + readStatus = http.StatusNotFound + } + for _, readPath := range []string{collection, path, path + "/progress"} { + assertCommentStatus(t, f.request(t, actor, http.MethodGet, readPath, nil), readStatus) + } + assertCommentStatus(t, f.request(t, actor, http.MethodPost, collection, iterationBody("Denied iteration", "planned")), http.StatusNotFound) + assertCommentStatus(t, f.request(t, actor, http.MethodPatch, path, map[string]any{"name": "Denied iteration"}), http.StatusNotFound) + assertCommentStatus(t, f.request(t, actor, http.MethodDelete, path, nil), http.StatusNotFound) + stored := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if !reflect.DeepEqual(stored, target) { + t.Fatal("denied write changed iteration") + } + rows, page := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, collection, nil)) + if len(rows) != 1 || page.TotalItems != 1 || rows[0].ID != target.ID { + t.Fatal("denied create/delete changed collection") + } + } + } + // Workspace Editor is sufficient locally, but not a global manager. + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodPost, "/iterations", iterationBody("Denied global", "planned")}, + {http.MethodPatch, iterationPath(global.ID), map[string]any{"name": "Denied global"}}, + {http.MethodDelete, iterationPath(global.ID), nil}, + } { + assertCommentStatus(t, f.request(t, f.editor, tc.method, tc.path, tc.body), http.StatusNotFound) + } + globalStored := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodGet, iterationPath(global.ID), nil), http.StatusOK) + globals, _ := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, "/iterations", nil)) + if !reflect.DeepEqual(globalStored, global) || len(globals) != 1 { + t.Fatal("workspace Editor changed global iteration") + } + changed := DecodeV2Document[models.Iteration](t, f.request(t, f.editor, http.MethodPatch, iterationPath(local.ID), map[string]any{"name": "Editor changed"}), http.StatusOK) + stored := DecodeV2Document[models.Iteration](t, f.request(t, f.viewer, http.MethodGet, iterationPath(local.ID), nil), http.StatusOK) + if changed.Name != "Editor changed" || stored.Name != changed.Name || stored.ID != local.ID { + t.Fatal("Editor local update did not persist") + } + assertCommentStatus(t, f.request(t, f.editor, http.MethodDelete, iterationPath(local.ID), nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, iterationPath(local.ID), nil), http.StatusNotFound) + }) +} + +func TestV2Iterations_Authentication(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + created := f.iteration(t, "/iterations", "Authenticated iteration", "planned") + workspace, _ := CreateTestWorkspace(t, f.admin, "Authenticated iterations", "IAU") + anonymous := *f.admin + anonymous.SessionCookie, anonymous.BearerToken = "", "" + path := iterationPath(created.ID) + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/iterations", nil}, {http.MethodGet, workspaceIterations(workspace), nil}, + {http.MethodGet, path, nil}, {http.MethodGet, path + "/progress", nil}, + {http.MethodPost, "/iterations", iterationBody("Anonymous", "planned")}, + {http.MethodPost, workspaceIterations(workspace), iterationBody("Anonymous", "planned")}, + {http.MethodPatch, path, map[string]any{"name": "Anonymous"}}, {http.MethodDelete, path, nil}, + } { + assertCommentStatus(t, f.request(t, &anonymous, tc.method, tc.path, tc.body), http.StatusUnauthorized) + } + stored := DecodeV2Document[models.Iteration](t, f.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + rows, _ := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, "/iterations", nil)) + local, _ := DecodeV2Page[models.Iteration](t, f.request(t, f.admin, http.MethodGet, workspaceIterations(workspace), nil)) + if !reflect.DeepEqual(stored, created) || len(rows) != 1 || len(local) != 0 { + t.Fatal("anonymous write changed persisted iterations") + } + }) +} + +func TestV2Iterations_GranularBearerScopes(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + f := &catalogFixture{admin: admin, bearer: true} + created := f.iteration(t, "/iterations", "Scoped iteration", "planned") + path := iterationPath(created.ID) + caller := func(scope string) *TestServer { + t.Helper() + actor := *admin + actor.SessionCookie = "" + actor.BearerToken = createTokenWithScopesAsUser(t, admin, "admin", "testpass123", []string{scope}) + return &actor + } + withoutIterationScope := caller("users:read") + identity := DecodeV2Document[models.User](t, f.request(t, withoutIterationScope, http.MethodGet, "/users/me", nil), http.StatusOK) + if identity.ID != lookupAdminUser(t, admin).ID { + t.Fatal("missing-scope control did not authenticate as the administrator") + } + for _, readPath := range []string{"/iterations", path, path + "/progress"} { + assertV2Error(t, f.request(t, withoutIterationScope, http.MethodGet, readPath, nil), http.StatusForbidden, "insufficient_permission") + } + reader := caller("iterations:read") + listed, _ := DecodeV2Page[models.Iteration](t, f.request(t, reader, http.MethodGet, "/iterations", nil)) + got := DecodeV2Document[models.Iteration](t, f.request(t, reader, http.MethodGet, path, nil), http.StatusOK) + progress := DecodeV2Document[services.IterationProgressReport](t, f.request(t, reader, http.MethodGet, path+"/progress", nil), http.StatusOK) + if len(listed) != 1 || listed[0].ID != created.ID || got.ID != created.ID || progress.IterationID != created.ID { + t.Fatal("read scope did not return the existing iteration") + } + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodPost, "/iterations", iterationBody("Denied scoped", "planned")}, + {http.MethodPatch, path, map[string]any{"name": "Denied scoped"}}, + {http.MethodDelete, path, nil}, + } { + assertV2Error(t, f.request(t, reader, tc.method, tc.path, tc.body), http.StatusForbidden, "insufficient_permission") + } + stored := DecodeV2Document[models.Iteration](t, f.request(t, admin, http.MethodGet, path, nil), http.StatusOK) + rows, _ := DecodeV2Page[models.Iteration](t, f.request(t, admin, http.MethodGet, "/iterations", nil)) + if !reflect.DeepEqual(stored, created) || len(rows) != 1 { + t.Fatal("read-only scope changed iterations") + } + writer := caller("iterations:write") + written := DecodeV2Document[models.Iteration](t, f.request(t, writer, http.MethodPost, "/iterations", iterationBody("Written iteration", "planned")), http.StatusCreated) + if written.ID <= 0 { + t.Fatal("write scope did not create iteration") + } + writePath := iterationPath(written.ID) + updated := DecodeV2Document[models.Iteration](t, f.request(t, writer, http.MethodPatch, writePath, map[string]any{"name": "Scoped update"}), http.StatusOK) + stored = DecodeV2Document[models.Iteration](t, f.request(t, writer, http.MethodGet, writePath, nil), http.StatusOK) + if updated.Name != "Scoped update" || !reflect.DeepEqual(stored, updated) { + t.Fatal("write scope did not persist update or imply read") + } + assertV2Error(t, f.request(t, writer, http.MethodDelete, writePath, nil), http.StatusForbidden, "insufficient_permission") + stored = DecodeV2Document[models.Iteration](t, f.request(t, admin, http.MethodGet, writePath, nil), http.StatusOK) + if !reflect.DeepEqual(stored, updated) { + t.Fatal("write-only token deleted iteration") + } + assertCommentStatus(t, f.request(t, caller("iterations:delete"), http.MethodDelete, writePath, nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, admin, http.MethodGet, writePath, nil), http.StatusNotFound) +} diff --git a/tests/v2_labels_test.go b/tests/v2_labels_test.go new file mode 100644 index 0000000..1117ff6 --- /dev/null +++ b/tests/v2_labels_test.go @@ -0,0 +1,172 @@ +package tests + +import ( + "fmt" + "net/http" + "slices" + "testing" + + "windshift/internal/models" +) + +func labelCatalogPath(workspace int) string { return fmt.Sprintf("/workspaces/%d/labels", workspace) } +func itemLabelsPath(item int) string { return fmt.Sprintf("/items/%d/labels", item) } + +func (f *itemRelationFixture) label(t *testing.T, workspace int, name string) models.Label { + t.Helper() + label := DecodeV2Document[models.Label](t, f.request(t, f.admin, http.MethodPost, labelCatalogPath(workspace), map[string]any{"name": name, "color": "#123456"}), http.StatusCreated) + if label.ID <= 0 || label.Name != name || label.Color != "#123456" { + t.Fatal("label create lost identity, name or color") + } + return label +} + +func assertLabelIDs(t *testing.T, labels []models.Label, want ...int) { + t.Helper() + got := make([]int, len(labels)) + for i, label := range labels { + got[i] = label.ID + } + slices.Sort(got) + want = slices.Clone(want) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("label IDs=%v, want %v", got, want) + } +} + +// Preserves the 15 former cookie LabelHandler scenarios through v2 HTTP. +// Labels remain global; workspace paths provide authorization context only. +func TestV2Labels_GlobalCatalogAndLegacyFilter(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + local, foreign := f.label(t, f.workspace, "Local"), f.label(t, f.foreign, "Foreign") + path := labelCatalogPath(f.workspace) + for _, query := range []string{"", "?workspace_id=999999"} { + labels := DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, path+query, nil), http.StatusOK) + assertLabelIDs(t, labels, local.ID, foreign.ID) + } + got := DecodeV2Document[models.Label](t, f.request(t, f.viewer, http.MethodGet, fmt.Sprintf("%s/%d", path, foreign.ID), nil), http.StatusOK) + if got.ID != foreign.ID || got.Name != foreign.Name { + t.Fatal("foreign-context label is missing from global catalog") + } + assertCommentStatus(t, f.request(t, f.outsider, http.MethodGet, path, nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.outsider, http.MethodGet, fmt.Sprintf("%s/%d", path, local.ID), nil), http.StatusNotFound) + // The invalid legacy query is ignored, but a missing path context is not. + assertCommentStatus(t, f.request(t, f.viewer, http.MethodGet, labelCatalogPath(999999), nil), http.StatusNotFound) + }) +} + +func TestV2Labels_DuplicateAcrossWorkspaceContexts(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + shared := f.label(t, f.workspace, "Shared") + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, labelCatalogPath(f.foreign), map[string]any{"name": "shared"}), http.StatusConflict) + labels := DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, labelCatalogPath(f.workspace), nil), http.StatusOK) + assertLabelIDs(t, labels, shared.ID) + if labels[0].Name != "Shared" { + t.Fatal("duplicate create changed existing name") + } + }) +} + +func TestV2Labels_ItemAssignmentsAndPermissions(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + local, foreign := f.label(t, f.workspace, "Local"), f.label(t, f.foreign, "Global") + path := itemLabelsPath(f.item) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK)) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.editor, http.MethodPut, path, map[string]any{"label_ids": []int{foreign.ID}}), http.StatusOK), foreign.ID) + other := *f + other.bearer = !f.bearer + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK), foreign.ID) + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertCommentStatus(t, f.request(t, actor, http.MethodPut, path, map[string]any{"label_ids": []int{local.ID}}), http.StatusNotFound) + assertCommentStatus(t, f.request(t, actor, http.MethodPost, path, map[string]any{"label_id": local.ID}), http.StatusNotFound) + assertCommentStatus(t, f.request(t, actor, http.MethodDelete, fmt.Sprintf("%s/%d", path, foreign.ID), nil), http.StatusNotFound) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK), foreign.ID) + } + assertCommentStatus(t, f.request(t, f.outsider, http.MethodGet, path, nil), http.StatusNotFound) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.editor, http.MethodPost, path, map[string]any{"label_id": local.ID}), http.StatusOK), local.ID, foreign.ID) + assertCommentStatus(t, f.request(t, f.editor, http.MethodDelete, fmt.Sprintf("%s/%d", path, local.ID), nil), http.StatusNoContent) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK), foreign.ID) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.editor, http.MethodPut, path, map[string]any{"label_ids": []int{}}), http.StatusOK)) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK)) + }) +} + +func TestV2Labels_Unauthenticated(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + label := f.label(t, f.workspace, "Protected") + catalog, assignment := labelCatalogPath(f.workspace), itemLabelsPath(f.item) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.editor, http.MethodPost, assignment, map[string]any{"label_id": label.ID}), http.StatusOK), label.ID) + anonymous := *f.admin + anonymous.SessionCookie, anonymous.BearerToken = "", "" + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, catalog, nil}, {http.MethodGet, fmt.Sprintf("%s/%d", catalog, label.ID), nil}, + {http.MethodPost, catalog, map[string]any{"name": "Anonymous"}}, + {http.MethodPatch, fmt.Sprintf("%s/%d", catalog, label.ID), map[string]any{"name": "Anonymous"}}, + {http.MethodDelete, fmt.Sprintf("%s/%d", catalog, label.ID), nil}, + {http.MethodGet, assignment, nil}, {http.MethodPut, assignment, map[string]any{"label_ids": []int{}}}, + {http.MethodPost, assignment, map[string]any{"label_id": label.ID}}, {http.MethodDelete, fmt.Sprintf("%s/%d", assignment, label.ID), nil}, + } { + t.Run(tc.method+tc.path, func(t *testing.T) { + assertCommentStatus(t, f.request(t, &anonymous, tc.method, tc.path, tc.body), http.StatusUnauthorized) + }) + } + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, assignment, nil), http.StatusOK), label.ID) + labels := DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, catalog, nil), http.StatusOK) + assertLabelIDs(t, labels, label.ID) + if labels[0].Name != label.Name { + t.Fatal("anonymous catalog mutation changed the label") + } + }) +} + +func TestV2Labels_MissingItem(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + label := f.label(t, f.workspace, "Existing") + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, itemLabelsPath(999999), nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, fmt.Sprintf("%s/%d", itemLabelsPath(999999), label.ID), nil), http.StatusNotFound) + }) +} + +func TestV2Labels_CatalogMutationPermissions(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + catalog := labelCatalogPath(f.workspace) + created := DecodeV2Document[models.Label](t, f.request(t, f.editor, http.MethodPost, catalog, map[string]any{"name": "Editable", "color": "#123456"}), http.StatusCreated) + if created.ID <= 0 || created.Name != "Editable" || created.Color != "#123456" { + t.Fatal("Editor label create lost required fields") + } + path := fmt.Sprintf("%s/%d", catalog, created.ID) + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertCommentStatus(t, f.request(t, actor, http.MethodPost, catalog, map[string]any{"name": "Denied"}), http.StatusNotFound) + } + // Editing/deleting a GLOBAL catalog entry requires workspace admin, + // whereas creating it and assigning it to an item require only Editor. + for _, actor := range []*TestServer{f.editor, f.viewer, f.outsider} { + assertCommentStatus(t, f.request(t, actor, http.MethodPatch, path, map[string]any{"name": "Denied"}), http.StatusNotFound) + assertCommentStatus(t, f.request(t, actor, http.MethodDelete, path, nil), http.StatusNotFound) + } + labels := DecodeV2Document[[]models.Label](t, f.request(t, f.viewer, http.MethodGet, catalog, nil), http.StatusOK) + assertLabelIDs(t, labels, created.ID) + if labels[0].Name != created.Name || labels[0].Color != created.Color { + t.Fatal("denied catalog writes changed the label") + } + updated := DecodeV2Document[models.Label](t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"color": "#654321"}), http.StatusOK) + if updated.ID != created.ID || updated.Name != created.Name || updated.Color != "#654321" { + t.Fatal("color-only PATCH lost the name or color") + } + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.Label](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != created.Name || stored.Color != updated.Color { + t.Fatal("catalog PATCH did not persist across mounts") + } + assignment := itemLabelsPath(f.item) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, f.request(t, f.editor, http.MethodPost, assignment, map[string]any{"label_id": created.ID}), http.StatusOK), created.ID) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, path, nil), http.StatusNoContent) + assertCommentStatus(t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusNotFound) + assertLabelIDs(t, DecodeV2Document[[]models.Label](t, other.request(t, f.viewer, http.MethodGet, assignment, nil), http.StatusOK)) + }) +} diff --git a/tests/v2_link_types_test.go b/tests/v2_link_types_test.go new file mode 100644 index 0000000..78b57c7 --- /dev/null +++ b/tests/v2_link_types_test.go @@ -0,0 +1,174 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" +) + +func linkTypePath(id int) string { return fmt.Sprintf("/link-types/%d", id) } + +// The 12 old cookie LinkTypeHandler scenarios are retained through v2 HTTP. +func TestV2LinkTypes_CRUD(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + body := map[string]any{"name": "Blocks", "description": "This item blocks another", "forward_label": "blocks", "reverse_label": "is blocked by", "color": "#ff0000"} + created := DecodeV2Document[models.LinkType](t, f.request(t, f.admin, http.MethodPost, "/link-types", body), http.StatusCreated) + if created.ID <= 0 || created.Name != "Blocks" || created.Description != "This item blocks another" || created.ForwardLabel != "blocks" || created.ReverseLabel != "is blocked by" || created.Color != "#ff0000" || !created.Active || created.IsSystem { + t.Fatal("link type create lost explicit fields or defaults") + } + path := linkTypePath(created.ID) + got := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if got.ID != created.ID || got.Name != created.Name || got.Description != created.Description { + t.Fatal("link type GET did not preserve identity and description") + } + second := f.linkType(t) + listed := DecodeV2Document[[]models.LinkType](t, f.request(t, f.viewer, http.MethodGet, "/link-types", nil), http.StatusOK) + seen := map[int]bool{} + for _, kind := range listed { + if seen[kind.ID] { + t.Fatal("duplicate link type in list") + } + seen[kind.ID] = true + } + if !seen[created.ID] || !seen[second] { + t.Fatal("list omitted created link types") + } + updated := DecodeV2Document[models.LinkType](t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"name": "Updated Name", "description": "Updated description", "forward_label": "updated forward", "reverse_label": "updated reverse", "color": "#abcdef", "active": true}), http.StatusOK) + if updated.ID != created.ID || updated.Name != "Updated Name" || updated.Description != "Updated description" || updated.ForwardLabel != "updated forward" || updated.ReverseLabel != "updated reverse" || updated.Color != "#abcdef" || !updated.Active { + t.Fatal("link type PATCH lost updated fields") + } + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.LinkType](t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.ID != updated.ID || stored.Name != updated.Name || stored.Description != updated.Description || stored.ForwardLabel != updated.ForwardLabel || stored.ReverseLabel != updated.ReverseLabel || stored.Color != updated.Color || !stored.Active { + t.Fatal("link type PATCH did not persist across mounts") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, path, nil), http.StatusNoContent) + assertCommentStatus(t, other.request(t, f.viewer, http.MethodGet, path, nil), http.StatusNotFound) + }) +} + +func TestV2LinkTypes_DefaultColorAndInactiveFiltering(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + id := f.linkType(t) + path := linkTypePath(id) + original := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if original.Color != "#6b7280" || !original.Active { + t.Fatal("missing color/active defaults") + } + inactive := DecodeV2Document[models.LinkType](t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"active": false}), http.StatusOK) + if inactive.Active || inactive.Name != original.Name || inactive.ForwardLabel != original.ForwardLabel || inactive.ReverseLabel != original.ReverseLabel || inactive.Color != original.Color { + t.Fatal("active-only PATCH lost omitted fields") + } + for _, tc := range []struct { + query string + visible bool + }{{"", false}, {"?include_inactive=true", true}} { + listed := DecodeV2Document[[]models.LinkType](t, f.request(t, f.viewer, http.MethodGet, "/link-types"+tc.query, nil), http.StatusOK) + found := 0 + for _, kind := range listed { + if kind.ID == id { + found++ + if kind.Active { + t.Fatal("list did not persist inactive state") + } + } + } + want := 0 + if tc.visible { + want = 1 + } + if found != want { + t.Fatalf("inactive entries=%d, want %d for %q", found, want, tc.query) + } + } + }) +} + +func TestV2LinkTypes_ValidationAndMissing(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + before := DecodeV2Document[[]models.LinkType](t, f.request(t, f.viewer, http.MethodGet, "/link-types", nil), http.StatusOK) + for _, field := range []string{"name", "forward_label", "reverse_label"} { + body := map[string]any{"name": "Invalid", "forward_label": "forward", "reverse_label": "reverse"} + delete(body, field) + t.Run(field, func(t *testing.T) { + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/link-types", body), http.StatusBadRequest, "name, forward_label, and reverse_label are required") + }) + } + after := DecodeV2Document[[]models.LinkType](t, f.request(t, f.viewer, http.MethodGet, "/link-types", nil), http.StatusOK) + if len(before) != len(after) { + t.Fatal("invalid creates changed the catalog") + } + id := f.linkType(t) + path := linkTypePath(id) + original := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"name": "", "forward_label": "forward", "reverse_label": "reverse"}), http.StatusBadRequest, "name, forward_label, and reverse_label are required") + stored := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != original.Name || stored.ForwardLabel != original.ForwardLabel || stored.ReverseLabel != original.ReverseLabel { + t.Fatal("invalid PATCH changed the catalog entry") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, linkTypePath(999999), nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, linkTypePath(999999), nil), http.StatusNotFound) + }) +} + +func TestV2LinkTypes_SystemAndAdminBoundaries(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + id := f.linkType(t) + path := linkTypePath(id) + for _, actor := range []*TestServer{f.editor, f.viewer, f.outsider} { + // The catalog is globally readable, but not globally writable. + got := DecodeV2Document[models.LinkType](t, f.request(t, actor, http.MethodGet, path, nil), http.StatusOK) + if got.ID != id { + t.Fatal("authenticated catalog reader got wrong type") + } + assertCommentStatus(t, f.request(t, actor, http.MethodPost, "/link-types", map[string]any{"name": "Denied", "forward_label": "forward", "reverse_label": "reverse"}), http.StatusForbidden) + assertCommentStatus(t, f.request(t, actor, http.MethodPatch, path, map[string]any{"name": "Denied"}), http.StatusForbidden) + assertCommentStatus(t, f.request(t, actor, http.MethodDelete, path, nil), http.StatusForbidden) + } + stored := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != "Relation blocks" { + t.Fatal("nonadmin mutation changed catalog entry") + } + listed := DecodeV2Document[[]models.LinkType](t, f.request(t, f.viewer, http.MethodGet, "/link-types", nil), http.StatusOK) + systemID := 0 + for _, kind := range listed { + if kind.Name == "Denied" { + t.Fatal("nonadmin create persisted") + } + if kind.IsSystem && systemID == 0 { + systemID = kind.ID + } + } + if systemID <= 0 { + t.Fatal("production bootstrap did not provide a system link type") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, linkTypePath(systemID), nil), http.StatusForbidden) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPatch, linkTypePath(systemID), map[string]any{"name": "Denied"}), http.StatusForbidden) + system := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, linkTypePath(systemID), nil), http.StatusOK) + if system.ID != systemID || !system.IsSystem || system.Name == "Denied" { + t.Fatal("system type protection did not preserve the record") + } + }) +} + +func TestV2LinkTypes_DeleteInUse(t *testing.T) { + runItemRelationCase(t, func(t *testing.T, f *itemRelationFixture) { + id, target := f.linkType(t), f.createItem(t, f.workspace, "Linked target") + link := DecodeV2Document[models.ItemLink](t, f.request(t, f.editor, http.MethodPost, "/links", relationBody(f.item, target, id)), http.StatusCreated) + if link.ID <= 0 || link.LinkTypeID != id { + t.Fatal("in-use fixture did not produce a typed link") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, linkTypePath(id), nil), http.StatusConflict) + stored := DecodeV2Document[models.LinkType](t, f.request(t, f.viewer, http.MethodGet, linkTypePath(id), nil), http.StatusOK) + got := f.itemLinks(t, f.viewer, f.item) + if stored.ID != id || len(got.Outgoing) != 1 || got.Outgoing[0].ID != link.ID { + t.Fatal("in-use deletion damaged the type or its link") + } + assertCommentStatus(t, f.request(t, f.editor, http.MethodDelete, fmt.Sprintf("/links/%d", link.ID), nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, linkTypePath(id), nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.viewer, http.MethodGet, linkTypePath(id), nil), http.StatusNotFound) + }) +} diff --git a/tests/v2_openapi_test.go b/tests/v2_openapi_test.go new file mode 100644 index 0000000..8ba562e --- /dev/null +++ b/tests/v2_openapi_test.go @@ -0,0 +1,79 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "testing" +) + +func TestV2OpenAPI_PublicJSONDiscovery(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + for _, prefix := range []string{"/rest/api/v1", "/api/v2", "/rest/api/v2"} { + t.Run(prefix, func(t *testing.T) { + response := makeRequest(t, http.MethodGet, server.BaseURL+prefix+"/openapi.json", "", nil, nil) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status=%d", response.StatusCode) + } + assertCommentJSON(t, response) + if response.Header.Get("Cache-Control") != "public, max-age=300" { + t.Fatalf("cache control=%q", response.Header.Get("Cache-Control")) + } + var doc struct { + OpenAPI string `json:"openapi"` + Paths map[string]json.RawMessage `json:"paths"` + } + if err := json.NewDecoder(response.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if doc.OpenAPI == "" || len(doc.Paths) == 0 { + t.Fatalf("empty discovery document: %+v", doc) + } + if prefix == "/rest/api/v1" { + return + } + if _, ok := doc.Paths["/items"]; !ok { + t.Fatal("v2 item path missing") + } + etag := response.Header.Get("ETag") + if etag == "" { + t.Fatal("v2 ETag missing") + } + cached := makeRequest(t, http.MethodGet, server.BaseURL+prefix+"/openapi.json", "", nil, map[string]string{"If-None-Match": etag}) + defer cached.Body.Close() + body, err := io.ReadAll(cached.Body) + if err != nil { + t.Fatal(err) + } + if cached.StatusCode != http.StatusNotModified || len(body) != 0 || cached.Header.Get("ETag") != etag { + t.Fatalf("conditional response=%d etag=%q body=%s", cached.StatusCode, cached.Header.Get("ETag"), body) + } + stale := makeRequest(t, http.MethodGet, server.BaseURL+prefix+"/openapi.json", "", nil, map[string]string{"If-None-Match": `"stale"`}) + defer stale.Body.Close() + var refreshed struct { + OpenAPI string `json:"openapi"` + Paths map[string]json.RawMessage `json:"paths"` + } + if stale.StatusCode != http.StatusOK || stale.Header.Get("ETag") != etag { + t.Fatalf("stale validator response=%d etag=%q", stale.StatusCode, stale.Header.Get("ETag")) + } + if err := json.NewDecoder(stale.Body).Decode(&refreshed); err != nil { + t.Fatal(err) + } + if refreshed.OpenAPI != doc.OpenAPI || len(refreshed.Paths) != len(doc.Paths) { + t.Fatalf("stale validator did not return discovery document") + } + }) + } +} + +// YAML discovery was removed; JSON is the supported public discovery format. +func TestV2OpenAPI_RemovedYAMLIsNotServed(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + for _, prefix := range []string{"/rest/api/v1", "/api/v2", "/rest/api/v2"} { + t.Run(prefix, func(t *testing.T) { + assertCommentStatus(t, makeRequest(t, http.MethodGet, server.BaseURL+prefix+"/openapi.yaml", "", nil, nil), http.StatusNotFound) + }) + } +} diff --git a/tests/v2_page_diagrams_test.go b/tests/v2_page_diagrams_test.go new file mode 100644 index 0000000..587b5d5 --- /dev/null +++ b/tests/v2_page_diagrams_test.go @@ -0,0 +1,117 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "windshift/internal/models" + "windshift/internal/services" +) + +func (f *diagramFixture) createPage(t *testing.T) models.Page { + t.Helper() + page := DecodeV2Document[models.Page](t, f.request(t, f.editor, http.MethodPost, fmt.Sprintf("/workspaces/%d/pages", f.workspace), map[string]any{ + "title": "Architecture", "content": "# Architecture", + }), http.StatusCreated) + if page.ID <= 0 || page.WorkspaceID != f.workspace || page.Content != "# Architecture" || page.ContentHash == "" { + t.Fatal("page fixture lost identity, content or hash") + } + return page +} + +func (f *diagramFixture) pagePath(pageID int) string { + return fmt.Sprintf("/workspaces/%d/pages/%d", f.workspace, pageID) +} + +// Preserves both old PageHandler diagram scenarios through the real v2 mounts. +func TestV2PageDiagrams_CreateListGetAndUpdate(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + page := f.createPage(t) + collection := f.pagePath(page.ID) + "/diagrams" + created := DecodeV2Document[services.PageDiagram](t, f.request(t, f.editor, http.MethodPost, collection, pageDiagramCreateBody(page.ContentHash)), http.StatusCreated) + if created.AttachmentID <= 0 || created.PageID != page.ID || created.Name != "Flow" || created.Kind != services.DiagramKindMermaid || created.ContentHash == "" || created.ContentHash == page.ContentHash { + t.Fatal("created page diagram lost identity, kind or changed content hash") + } + listed := DecodeV2Document[[]services.PageDiagram](t, f.request(t, f.viewer, http.MethodGet, collection, nil), http.StatusOK) + if len(listed) != 1 || listed[0].AttachmentID != created.AttachmentID { + t.Fatal("list did not return exactly the created diagram") + } + path := collection + "/" + strconv.Itoa(created.AttachmentID) + fetched := DecodeV2Document[services.PageDiagram](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + var seed map[string]string + if err := json.Unmarshal(fetched.Payload, &seed); err != nil || seed["source"] != "graph TD; A-->B" { + t.Fatal("stored Mermaid payload did not preserve its source") + } + scene := map[string]any{ + "elements": []map[string]any{{"id": "one", "type": "rectangle"}}, + "appState": map[string]any{}, "files": map[string]any{}, + } + body := map[string]any{"name": "Renamed flow", "excalidraw": scene, "expected_content_hash": created.ContentHash} + updated := DecodeV2Document[services.PageDiagram](t, f.request(t, f.editor, http.MethodPatch, path, body), http.StatusOK) + if updated.AttachmentID <= 0 || updated.AttachmentID == created.AttachmentID || updated.PageID != page.ID || updated.Name != "Renamed flow" || updated.Kind != services.DiagramKindExcalidraw || updated.ContentHash == "" || updated.ContentHash == created.ContentHash { + t.Fatal("updated diagram did not replace the immutable attachment and page hash") + } + other := *f + other.bearer = !f.bearer + current := DecodeV2Document[models.Page](t, other.request(t, f.viewer, http.MethodGet, f.pagePath(page.ID), nil), http.StatusOK) + if current.ContentHash != updated.ContentHash || strings.Contains(current.Content, `"attachmentId":`+strconv.Itoa(created.AttachmentID)) || + !strings.Contains(current.Content, `"attachmentId":`+strconv.Itoa(updated.AttachmentID)) || !strings.Contains(current.Content, `"name":"Renamed flow"`) { + t.Fatal("stored page did not replace the diagram reference and name") + } + stored := DecodeV2Document[services.PageDiagram](t, other.request(t, f.viewer, http.MethodGet, collection+"/"+strconv.Itoa(updated.AttachmentID), nil), http.StatusOK) + var payload struct { + Elements []struct{ ID, Type string } `json:"elements"` + } + if err := json.Unmarshal(stored.Payload, &payload); err != nil || len(payload.Elements) != 1 || payload.Elements[0].ID != "one" || payload.Elements[0].Type != "rectangle" { + t.Fatal("updated Excalidraw scene did not persist across mounts") + } + listed = DecodeV2Document[[]services.PageDiagram](t, f.request(t, f.viewer, http.MethodGet, collection, nil), http.StatusOK) + if len(listed) != 1 || listed[0].AttachmentID != updated.AttachmentID { + t.Fatal("diagram collection did not follow the updated page reference") + } + }) +} + +func TestV2PageDiagrams_MutationMasksCrossWorkspaceAndViewerDenials(t *testing.T) { + runDiagramCase(t, func(t *testing.T, f *diagramFixture) { + page := f.createPage(t) + collection := f.pagePath(page.ID) + "/diagrams" + cross := fmt.Sprintf("/workspaces/%d/pages/%d/diagrams", f.otherWorkspace, page.ID) + body := pageDiagramCreateBody(page.ContentHash) + assertDiagramStatus(t, f.request(t, f.admin, http.MethodPost, cross, body), http.StatusNotFound) + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertDiagramStatus(t, f.request(t, actor, http.MethodPost, collection, body), http.StatusNotFound) + } + if diagrams := DecodeV2Document[[]services.PageDiagram](t, f.request(t, f.viewer, http.MethodGet, collection, nil), http.StatusOK); len(diagrams) != 0 { + t.Fatal("denied diagram create persisted an attachment reference") + } + created := DecodeV2Document[services.PageDiagram](t, f.request(t, f.editor, http.MethodPost, collection, body), http.StatusCreated) + if created.AttachmentID <= 0 || created.ContentHash == "" { + t.Fatal("authorized diagram create did not produce an attachment and hash") + } + path := collection + "/" + strconv.Itoa(created.AttachmentID) + update := map[string]any{"name": "Denied", "mermaid": "graph TD; B-->C", "expected_content_hash": created.ContentHash} + assertDiagramStatus(t, f.request(t, f.admin, http.MethodPatch, cross+"/"+strconv.Itoa(created.AttachmentID), update), http.StatusNotFound) + for _, actor := range []*TestServer{f.viewer, f.outsider} { + assertDiagramStatus(t, f.request(t, actor, http.MethodPatch, path, update), http.StatusNotFound) + } + assertDiagramStatus(t, f.request(t, f.outsider, http.MethodGet, collection, nil), http.StatusNotFound) + assertDiagramStatus(t, f.request(t, f.outsider, http.MethodGet, path, nil), http.StatusNotFound) + // An old hash also rejects an otherwise authorized writer. + update["expected_content_hash"] = page.ContentHash + assertDiagramStatus(t, f.request(t, f.editor, http.MethodPatch, path, update), http.StatusConflict) + current := DecodeV2Document[models.Page](t, f.request(t, f.viewer, http.MethodGet, f.pagePath(page.ID), nil), http.StatusOK) + if current.ContentHash != created.ContentHash || !strings.Contains(current.Content, `"name":"Flow"`) || strings.Contains(current.Content, "Denied") { + t.Fatal("denied or conflicting PATCH changed the stored page") + } + stored := DecodeV2Document[services.PageDiagram](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + var seed map[string]string + if err := json.Unmarshal(stored.Payload, &seed); err != nil || seed["source"] != "graph TD; A-->B" || stored.Name != "Flow" { + t.Fatal("denied or conflicting PATCH changed the stored diagram") + } + }) +} diff --git a/tests/v2_page_labels_test.go b/tests/v2_page_labels_test.go new file mode 100644 index 0000000..2f0b054 --- /dev/null +++ b/tests/v2_page_labels_test.go @@ -0,0 +1,219 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" +) + +type pageLabelFixture struct { + admin, editor, viewer, outsider *TestServer + workspace, otherWorkspace int + bearer bool +} + +// Each case runs through both real mounts with independent production fixtures. +// User creation and role assignment intentionally use retained bootstrap APIs. +func runPageLabelCase(t *testing.T, test func(*testing.T, *pageLabelFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + admin, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, admin) + workspace := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, admin, http.MethodPost, "/workspaces", map[string]any{"name": "Labels", "key": "LBL"}), http.StatusCreated) + other := DecodeV2Document[v2FixtureRecord](t, MakeV2SessionRequest(t, admin, http.MethodPost, "/workspaces", map[string]any{"name": "Other labels", "key": "OTHERLBL"}), http.StatusCreated) + actor := func(name, role string) *TestServer { + id, username, password := CreateTestUserWithCredentials(t, admin, name, name+"@example.test") + if role != "" { + AssignWorkspaceRole(t, admin, id, workspace.ID, role) + } + cookie, token := CreateAuthCredentialsForUser(t, admin, username, password) + actorServer := *admin + actorServer.SessionCookie, actorServer.BearerToken = cookie, token + return &actorServer + } + f := &pageLabelFixture{admin: admin, workspace: workspace.ID, otherWorkspace: other.ID, bearer: bearer} + f.editor = actor("label-editor", "Editor") + // An explicit Viewer grant closes the implicit everyone-role fallback. + f.viewer = actor("label-viewer", "Viewer") + f.outsider = actor("label-outsider", "") + test(t, f) + }) + } +} + +func (f *pageLabelFixture) request(t *testing.T, actor *TestServer, method, path string, body any) *http.Response { + t.Helper() + if f.bearer { + return MakeV2BearerRequest(t, actor, method, path, body) + } + return MakeV2SessionRequest(t, actor, method, path, body) +} + +func (f *pageLabelFixture) label(t *testing.T, workspace int, name string) models.PageLabel { + t.Helper() + return DecodeV2Document[models.PageLabel](t, f.request(t, f.admin, http.MethodPost, fmt.Sprintf("/workspaces/%d/page-labels", workspace), map[string]any{"name": name}), http.StatusCreated) +} + +func (f *pageLabelFixture) page(t *testing.T) int { + t.Helper() + return DecodeV2Document[v2FixtureRecord](t, f.request(t, f.editor, http.MethodPost, fmt.Sprintf("/workspaces/%d/pages", f.workspace), map[string]any{"title": "Label page"}), http.StatusCreated).ID +} + +func (f *pageLabelFixture) labelsPath(pageID int) string { + return fmt.Sprintf("/workspaces/%d/pages/%d/labels", f.workspace, pageID) +} + +func assertPageLabelStatus(t *testing.T, response *http.Response, status int) { + t.Helper() + defer response.Body.Close() + AssertStatusCode(t, response, status) +} + +func TestV2PageLabels_Create_RejectsViewer(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + path := fmt.Sprintf("/workspaces/%d/page-labels", f.workspace) + assertPageLabelStatus(t, f.request(t, f.viewer, http.MethodPost, path, map[string]any{"name": "design"}), http.StatusNotFound) + labels := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.admin, http.MethodGet, path, nil), http.StatusOK) + if len(labels) != 0 { + t.Fatalf("denied create persisted labels: %+v", labels) + } + }) +} + +func TestV2PageLabels_Create_EditorSucceeds(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + label := DecodeV2Document[models.PageLabel](t, f.request(t, f.editor, http.MethodPost, fmt.Sprintf("/workspaces/%d/page-labels", f.workspace), map[string]any{"name": "design"}), http.StatusCreated) + if label.ID <= 0 || label.Name != "design" || label.Color != "#3B82F6" || label.WorkspaceID != f.workspace { + t.Fatalf("label=%+v", label) + } + }) +} + +func TestV2PageLabels_Get_404ForCrossWorkspace(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + own := f.label(t, f.workspace, "visible") + visible := DecodeV2Document[models.PageLabel](t, f.request(t, f.editor, http.MethodGet, fmt.Sprintf("/workspaces/%d/page-labels/%d", f.workspace, own.ID), nil), http.StatusOK) + if visible.ID != own.ID || visible.Name != own.Name { + t.Fatalf("own workspace label=%+v", visible) + } + other := f.label(t, f.otherWorkspace, "secret") + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodGet, fmt.Sprintf("/workspaces/%d/page-labels/%d", f.workspace, other.ID), nil), http.StatusNotFound) + }) +} + +func TestV2PageLabels_Delete_CascadesAssignments(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + page, label := f.page(t), f.label(t, f.workspace, "urgent") + path := f.labelsPath(page) + before := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodPost, path, map[string]any{"label_id": label.ID}), http.StatusOK) + if len(before) != 1 || before[0].ID != label.ID { + t.Fatalf("assignment before delete=%+v", before) + } + labelPath := fmt.Sprintf("/workspaces/%d/page-labels/%d", f.workspace, label.ID) + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodDelete, labelPath, nil), http.StatusNoContent) + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodGet, labelPath, nil), http.StatusNotFound) + after := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(after) != 0 { + t.Fatalf("assignments after label deletion=%+v", after) + } + }) +} + +func TestV2PageLabels_AttachDetach_PerPagePermission(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + page, label := f.page(t), f.label(t, f.workspace, "design") + path := f.labelsPath(page) + body := map[string]any{"label_id": label.ID} + assertPageLabelStatus(t, f.request(t, f.viewer, http.MethodPost, path, body), http.StatusNotFound) + before := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(before) != 0 { + t.Fatalf("denied attach persisted: %+v", before) + } + attached := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodPost, path, body), http.StatusOK) + if len(attached) != 1 || attached[0].ID != label.ID { + t.Fatalf("attached=%+v", attached) + } + detach := fmt.Sprintf("%s/%d", path, label.ID) + assertPageLabelStatus(t, f.request(t, f.viewer, http.MethodDelete, detach, nil), http.StatusNotFound) + retained := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(retained) != 1 || retained[0].ID != label.ID { + t.Fatalf("denied detach changed assignment: %+v", retained) + } + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodDelete, detach, nil), http.StatusNoContent) + after := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(after) != 0 { + t.Fatalf("detach did not persist: %+v", after) + } + }) +} + +func TestV2PageLabels_AttachRejectsCrossWorkspaceLabel(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + page, label := f.page(t), f.label(t, f.otherWorkspace, "other") + path := f.labelsPath(page) + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodPost, path, map[string]any{"label_id": label.ID}), http.StatusNotFound) + labels := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(labels) != 0 { + t.Fatalf("cross-workspace attach persisted: %+v", labels) + } + }) +} + +func TestV2PageLabels_List_RequiresWorkspaceView(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + label := f.label(t, f.workspace, "design") + path := fmt.Sprintf("/workspaces/%d/page-labels", f.workspace) + assertPageLabelStatus(t, f.request(t, f.outsider, http.MethodGet, path, nil), http.StatusNotFound) + visible := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if len(visible) != 1 || visible[0].ID != label.ID { + t.Fatalf("viewer labels=%+v", visible) + } + }) +} + +func TestV2PageLabels_NameUniquenessPerWorkspace(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + first := f.label(t, f.workspace, "design") + path := fmt.Sprintf("/workspaces/%d/page-labels", f.workspace) + assertPageLabelStatus(t, f.request(t, f.editor, http.MethodPost, path, map[string]any{"name": "design"}), http.StatusConflict) + labels := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodGet, path, nil), http.StatusOK) + if len(labels) != 1 || labels[0].ID != first.ID { + t.Fatalf("duplicate changed labels=%+v", labels) + } + other := f.label(t, f.otherWorkspace, "design") + if other.ID == first.ID || other.WorkspaceID != f.otherWorkspace { + t.Fatalf("cross-workspace name reuse=%+v", other) + } + }) +} + +func TestV2PageLabels_PreloadLabelsOnPageDetail(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + page, label := f.page(t), f.label(t, f.workspace, "design") + DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodPost, f.labelsPath(page), map[string]any{"label_id": label.ID}), http.StatusOK) + detail := DecodeV2Document[struct { + Labels []models.PageLabel `json:"labels"` + }](t, f.request(t, f.viewer, http.MethodGet, fmt.Sprintf("/workspaces/%d/pages/%d", f.workspace, page), nil), http.StatusOK) + if len(detail.Labels) != 1 || detail.Labels[0].ID != label.ID || detail.Labels[0].Name != "design" { + t.Fatalf("preloaded labels=%+v", detail.Labels) + } + }) +} + +func TestV2PageLabels_SetForPage_DedupesDuplicateIDs(t *testing.T) { + runPageLabelCase(t, func(t *testing.T, f *pageLabelFixture) { + page, label := f.page(t), f.label(t, f.workspace, "design") + path := f.labelsPath(page) + attached := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.editor, http.MethodPut, path, map[string]any{"label_ids": []int{label.ID, label.ID}}), http.StatusOK) + if len(attached) != 1 || attached[0].ID != label.ID { + t.Fatalf("deduplicated labels=%+v", attached) + } + stored := DecodeV2Document[[]models.PageLabel](t, f.request(t, f.viewer, http.MethodGet, path, nil), http.StatusOK) + if len(stored) != 1 || stored[0].ID != label.ID { + t.Fatalf("persisted labels=%+v", stored) + } + }) +} diff --git a/tests/v2_priorities_test.go b/tests/v2_priorities_test.go new file mode 100644 index 0000000..7923b88 --- /dev/null +++ b/tests/v2_priorities_test.go @@ -0,0 +1,173 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "windshift/internal/models" +) + +func priorityPath(id int) string { return fmt.Sprintf("/priorities/%d", id) } + +func (f *catalogFixture) priority(t *testing.T, name string, isDefault bool) models.Priority { + t.Helper() + priority := DecodeV2Document[models.Priority](t, f.request(t, f.admin, http.MethodPost, "/priorities", map[string]any{"name": name, "is_default": isDefault}), http.StatusCreated) + if priority.ID <= 0 || priority.Name != name || priority.IsDefault != isDefault { + t.Fatal("priority fixture lost identity, name or default state") + } + return priority +} + +// Preserves all 12 removed cookie PriorityHandler scenarios via real v2 HTTP. +func TestV2Priorities_CRUD(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + created := DecodeV2Document[models.Priority](t, f.request(t, f.admin, http.MethodPost, "/priorities", map[string]any{"name": "Super Critical", "description": "Super critical priority items", "icon": "flame", "color": "#ff0000", "sort_order": 1}), http.StatusCreated) + if created.ID <= 0 || created.Name != "Super Critical" || created.Description != "Super critical priority items" || created.Icon != "flame" || created.Color != "#ff0000" || created.SortOrder != 1 { + t.Fatal("priority create lost requested fields") + } + path := priorityPath(created.ID) + got := DecodeV2Document[models.Priority](t, f.request(t, f.reader, http.MethodGet, path, nil), http.StatusOK) + if got.ID != created.ID || got.Name != created.Name || got.Description != created.Description { + t.Fatal("priority GET lost identity or description") + } + second, third := f.priority(t, "Second custom", false), f.priority(t, "Third custom", false) + listed := DecodeV2Document[[]models.Priority](t, f.request(t, f.reader, http.MethodGet, "/priorities", nil), http.StatusOK) + seen := map[int]bool{} + for _, priority := range listed { + if seen[priority.ID] { + t.Fatal("priority list has duplicate IDs") + } + seen[priority.ID] = true + } + if !seen[created.ID] || !seen[second.ID] || !seen[third.ID] { + t.Fatal("priority list omitted one of the three custom priorities") + } + updated := DecodeV2Document[models.Priority](t, f.request(t, f.admin, http.MethodPatch, path, map[string]any{"name": "Updated Priority", "description": "Updated description", "icon": "flag", "color": "#222222", "sort_order": 2}), http.StatusOK) + if updated.ID != created.ID || updated.Name != "Updated Priority" || updated.Description != "Updated description" || updated.Icon != "flag" || updated.Color != "#222222" || updated.SortOrder != 2 { + t.Fatal("priority PATCH lost updated fields") + } + other := *f + other.bearer = !f.bearer + stored := DecodeV2Document[models.Priority](t, other.request(t, f.reader, http.MethodGet, path, nil), http.StatusOK) + if stored.Name != updated.Name || stored.Description != updated.Description || stored.Icon != updated.Icon || stored.Color != updated.Color || stored.SortOrder != updated.SortOrder { + t.Fatal("priority PATCH did not persist across mounts") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, path, nil), http.StatusNoContent) + assertCommentStatus(t, other.request(t, f.reader, http.MethodGet, path, nil), http.StatusNotFound) + }) +} + +func TestV2Priorities_DefaultUniqueness(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + first := f.priority(t, "First Default", true) + second := f.priority(t, "Second Default", true) + stored := DecodeV2Document[models.Priority](t, f.request(t, f.reader, http.MethodGet, priorityPath(first.ID), nil), http.StatusOK) + if stored.IsDefault { + t.Fatal("creating another default did not clear the first") + } + defaults := func(want int) { + t.Helper() + listed := DecodeV2Document[[]models.Priority](t, f.request(t, f.reader, http.MethodGet, "/priorities", nil), http.StatusOK) + count := 0 + for _, priority := range listed { + if priority.IsDefault { + count++ + if priority.ID != want { + t.Fatal("unexpected default priority") + } + } + } + if count != 1 { + t.Fatalf("default count=%d, want one", count) + } + } + defaults(second.ID) + updated := DecodeV2Document[models.Priority](t, f.request(t, f.admin, http.MethodPatch, priorityPath(first.ID), map[string]any{"is_default": true}), http.StatusOK) + if !updated.IsDefault || updated.Name != first.Name { + t.Fatal("default-only PATCH lost name or default state") + } + defaults(first.ID) + }) +} + +func TestV2Priorities_ValidationDuplicatesAndMissing(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + before := DecodeV2Document[[]models.Priority](t, f.request(t, f.reader, http.MethodGet, "/priorities", nil), http.StatusOK) + for _, body := range []map[string]any{{"description": "Description only"}, {"name": " ", "description": "Description"}} { + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/priorities", body), http.StatusBadRequest, "priority name is required") + } + created := f.priority(t, "Duplicate Test", false) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPost, "/priorities", map[string]any{"name": created.Name}), http.StatusConflict) + assertCommentStatus(t, f.request(t, f.admin, http.MethodPatch, priorityPath(created.ID), map[string]any{"name": "", "color": "#444444"}), http.StatusBadRequest, "priority name is required") + stored := DecodeV2Document[models.Priority](t, f.request(t, f.reader, http.MethodGet, priorityPath(created.ID), nil), http.StatusOK) + if stored.Name != created.Name || stored.Color != created.Color { + t.Fatal("invalid PATCH changed priority") + } + after := DecodeV2Document[[]models.Priority](t, f.request(t, f.reader, http.MethodGet, "/priorities", nil), http.StatusOK) + if len(after) != len(before)+1 { + t.Fatal("invalid/duplicate creates changed catalog size") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodGet, priorityPath(999999), nil), http.StatusNotFound) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, priorityPath(999999), nil), http.StatusNotFound) + }) +} + +func TestV2Priorities_DeleteInUse(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + priority := f.priority(t, "In Use Priority", false) + workspace, _ := CreateTestWorkspace(t, f.admin, "Priority usage", "PRIO") + item := DecodeV2Document[models.Item](t, f.request(t, f.admin, http.MethodPost, "/items", map[string]any{"workspace_id": workspace, "title": "Priority reference", "priority_id": priority.ID}), http.StatusCreated) + if item.ID <= 0 || item.PriorityID == nil || *item.PriorityID != priority.ID { + t.Fatal("in-use fixture did not persist priority reference") + } + assertV2Error(t, f.request(t, f.admin, http.MethodDelete, priorityPath(priority.ID), nil), http.StatusConflict, "conflict", "priority is used by 1 work items") + stored := DecodeV2Document[models.Priority](t, f.request(t, f.reader, http.MethodGet, priorityPath(priority.ID), nil), http.StatusOK) + itemPath := fmt.Sprintf("/items/%d", item.ID) + linked := DecodeV2Document[models.Item](t, f.request(t, f.admin, http.MethodGet, itemPath, nil), http.StatusOK) + if stored.ID != priority.ID || linked.PriorityID == nil || *linked.PriorityID != priority.ID { + t.Fatal("in-use deletion damaged priority or item reference") + } + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, itemPath, nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.admin, http.MethodDelete, priorityPath(priority.ID), nil), http.StatusNoContent) + assertCommentStatus(t, f.request(t, f.reader, http.MethodGet, priorityPath(priority.ID), nil), http.StatusNotFound) + }) +} + +func TestV2Priorities_AuthenticationAndAdminBoundaries(t *testing.T) { + runCatalogCase(t, func(t *testing.T, f *catalogFixture) { + priority := f.priority(t, "Protected priority", false) + path := priorityPath(priority.ID) + anonymous := *f.admin + anonymous.SessionCookie, anonymous.BearerToken = "", "" + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/priorities", nil}, {http.MethodGet, path, nil}, + {http.MethodPost, "/priorities", map[string]any{"name": "Denied"}}, + {http.MethodPatch, path, map[string]any{"name": "Denied"}}, {http.MethodDelete, path, nil}, + } { + assertCommentStatus(t, f.request(t, &anonymous, tc.method, tc.path, tc.body), http.StatusUnauthorized) + if tc.method != http.MethodGet { + assertV2Error(t, f.request(t, f.reader, tc.method, tc.path, tc.body), http.StatusForbidden, "forbidden", "System administration permission is required") + } + } + listed := DecodeV2Document[[]models.Priority](t, f.request(t, f.reader, http.MethodGet, "/priorities", nil), http.StatusOK) + found := false + for _, p := range listed { + if p.Name == "Denied" { + t.Fatal("unauthorized create/PATCH persisted") + } + if p.ID == priority.ID { + found = true + if p.Name != priority.Name { + t.Fatal("protected priority was changed") + } + } + } + if !found { + t.Fatal("unauthorized deletion removed priority") + } + }) +} diff --git a/tests/v2_test_management_test.go b/tests/v2_test_management_test.go new file mode 100644 index 0000000..2b687da --- /dev/null +++ b/tests/v2_test_management_test.go @@ -0,0 +1,250 @@ +package tests + +import ( + "fmt" + "net/http" + "strings" + "testing" + v2 "windshift/internal/restapi/v2" +) + +type managementRecord struct { + ID int `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + ActualResult string `json:"actual_result"` + Notes string `json:"notes"` + TestCaseID int `json:"test_case_id"` + PlanID int `json:"plan_id"` +} +type managementFixture struct { + server *TestServer + workspace int + bearer bool +} + +func runManagementCase(t *testing.T, test func(*testing.T, *managementFixture)) { + t.Helper() + for _, bearer := range []bool{false, true} { + t.Run(fmt.Sprintf("bearer=%t", bearer), func(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + f := &managementFixture{server: server, bearer: bearer} + f.workspace = f.workspaceNamed(t, "Management", "MGMT") + test(t, f) + }) + } +} +func (f *managementFixture) request(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + if f.bearer { + return MakeV2BearerRequest(t, f.server, method, path, body) + } + return MakeV2SessionRequest(t, f.server, method, path, body) +} +func (f *managementFixture) workspaceNamed(t *testing.T, name, key string) int { + t.Helper() + return DecodeV2Document[managementRecord](t, f.request(t, http.MethodPost, "/workspaces", map[string]any{"name": name, "key": key}), http.StatusCreated).ID +} +func (f *managementFixture) path(resource string) string { + return fmt.Sprintf("/workspaces/%d/%s", f.workspace, resource) +} +func (f *managementFixture) create(t *testing.T, resource string, body any) managementRecord { + t.Helper() + return DecodeV2Document[managementRecord](t, f.request(t, http.MethodPost, f.path(resource), body), http.StatusCreated) +} +func (f *managementFixture) attach(t *testing.T, plan, caseID int) { + t.Helper() + DecodeV2Document[map[string]any](t, f.request(t, http.MethodPost, f.path(fmt.Sprintf("test-plans/%d/test-cases", plan)), map[string]any{"test_case_id": caseID}), http.StatusOK) +} +func (f *managementFixture) seed(t *testing.T) (int, int) { + t.Helper() + c := f.create(t, "test-cases", map[string]any{"title": "Test case", "priority": "medium"}) + p := f.create(t, "test-plans", map[string]any{"name": "Test plan"}) + f.attach(t, p.ID, c.ID) + return c.ID, p.ID +} +func (f *managementFixture) results(t *testing.T, runID int) []managementRecord { + t.Helper() + return DecodeV2Document[[]managementRecord](t, f.request(t, http.MethodGet, f.path(fmt.Sprintf("test-runs/%d/results", runID)), nil), http.StatusOK) +} +func TestV2TestManagementAggregateRoutes(t *testing.T) { + runManagementCase(t, func(t *testing.T, f *managementFixture) { + caseID, planID := f.seed(t) + cases, page := DecodeV2Page[managementRecord](t, f.request(t, http.MethodGet, f.path("test-cases"), nil)) + if len(cases) != 1 || cases[0].ID != caseID || page.TotalItems != 1 { + t.Fatalf("cases=%+v page=%+v", cases, page) + } + run := f.create(t, "test-runs", map[string]any{"name": "Aggregate run", "plan_id": planID}) + detail := DecodeV2Document[struct { + Run managementRecord `json:"run"` + TestCases []managementRecord `json:"test_cases"` + }](t, f.request(t, http.MethodGet, f.path(fmt.Sprintf("test-runs/%d/detail", run.ID)), nil), http.StatusOK) + if detail.Run.ID != run.ID || len(detail.TestCases) != 1 || detail.TestCases[0].ID != caseID { + t.Fatalf("detail=%+v", detail) + } + }) +} +func TestV2TestRunExecution_SessionAndBearerContract(t *testing.T) { + runManagementCase(t, func(t *testing.T, f *managementFixture) { + caseID, planID := f.seed(t) + run := f.create(t, "test-runs", map[string]any{"name": "Parity run", "plan_id": planID}) + if run.Name != "Parity run" || run.PlanID != planID { + t.Fatalf("run=%+v", run) + } + results := f.results(t, run.ID) + if len(results) != 1 || results[0].ID <= 0 || results[0].TestCaseID != caseID { + t.Fatalf("results=%+v", results) + } + path := f.path(fmt.Sprintf("test-runs/%d/results/%d", run.ID, results[0].ID)) + DecodeV2Document[managementRecord](t, f.request(t, http.MethodPatch, path, map[string]any{"status": "failed", "actual_result": "cookie
result", "notes": "cookie note"}), http.StatusOK) + f.bearer = !f.bearer // Verify persistence across mounts, in both directions. + after := f.results(t, run.ID) + if len(after) != 1 || after[0].Status != "failed" || strings.Contains(after[0].ActualResult, "Shared plan", "description": "beforeafter"}) + if plan.Name != "Shared plan" || strings.Contains(plan.Description, "Updated plan", "description": "updateddescription"}), http.StatusOK) + f.bearer = !f.bearer + stored := DecodeV2Document[managementRecord](t, f.request(t, http.MethodGet, f.path(fmt.Sprintf("test-plans/%d", plan.ID)), nil), http.StatusOK) + if stored.Name != "Updated plan" || strings.Contains(stored.Description, "Shared template", "description": "before
after"}) + if template.Name != "Shared template" || strings.Contains(template.Description, "