diff --git a/.github/workflows/core-main.yml b/.github/workflows/core-main.yml index 289e94e..7786062 100644 --- a/.github/workflows/core-main.yml +++ b/.github/workflows/core-main.yml @@ -14,7 +14,9 @@ concurrency: jobs: compatibility: - runs-on: ubuntu-latest + # Playwright's bundled Chromium does not yet support ubuntu-26.04, which + # ubuntu-latest now resolves to; pin to 24.04 until Playwright catches up. + runs-on: ubuntu-24.04 timeout-minutes: 120 env: CI: "true" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c31088..bf43c2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,7 +74,9 @@ jobs: run: ./test --core ../core quick browser: - runs-on: ubuntu-latest + # Playwright's bundled Chromium does not yet support ubuntu-26.04, which + # ubuntu-latest now resolves to; pin to 24.04 until Playwright catches up. + runs-on: ubuntu-24.04 timeout-minutes: 45 env: CI: "true" diff --git a/core.ref b/core.ref index 764fb14..3a638da 100644 --- a/core.ref +++ b/core.ref @@ -1 +1 @@ -240a7d59bbe3a0929b117d8cfc10149beeb168ab \ No newline at end of file +0ebcef99eac93a2e7b0db03dd39b0fff519ac0c1 \ No newline at end of file diff --git a/e2e/fixtures/api-helpers.ts b/e2e/fixtures/api-helpers.ts index 586c5d4..2bee1d1 100644 --- a/e2e/fixtures/api-helpers.ts +++ b/e2e/fixtures/api-helpers.ts @@ -313,7 +313,8 @@ export async function listCustomFieldsViaAPI(request: APIRequestContext) { } /** - * Create a custom field via the API (bypasses UI for faster test setup) + * Create a custom field via the API (bypasses UI for faster test setup). + * Uses the v2 contract; the response wraps the field in `custom_field`. */ export async function createCustomFieldViaAPI( request: APIRequestContext, @@ -328,26 +329,30 @@ export async function createCustomFieldViaAPI( }, ) { const { field_name, ...rest } = data; - const response = await request.post(`${BASE_URL}/api/admin/custom-fields`, { + const response = await request.post(`${BASE_URL}/api/v2/custom-fields`, { headers: defaultHeaders, data: { ...rest, name: data.name ?? field_name, }, }); - expect(response.ok()).toBeTruthy(); - return response.json(); + expect( + response.ok(), + `create custom field failed (${response.status()}): ${await response.text()}`, + ).toBeTruthy(); + const body = await response.json(); + return body.data?.custom_field ?? body.data ?? body; } /** - * Delete a custom field via the API + * Delete a custom field via the API (v2). */ export async function deleteCustomFieldViaAPI( request: APIRequestContext, fieldId: number, ) { const response = await request.delete( - `${BASE_URL}/api/admin/custom-fields/${fieldId}`, + `${BASE_URL}/api/v2/custom-fields/${fieldId}`, { headers: defaultHeaders, }, diff --git a/e2e/fixtures/global-lock.ts b/e2e/fixtures/global-lock.ts new file mode 100644 index 0000000..dadbb7a --- /dev/null +++ b/e2e/fixtures/global-lock.ts @@ -0,0 +1,42 @@ +import { mkdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Playwright's serial mode only orders tests inside one file, so specs that +// mutate a global singleton (the active theme) can still run concurrently in +// separate workers. Coordinate them with a directory that only one worker can +// create; the holder removes it on release. +const LOCK_ROOT = join(tmpdir(), "windshift-e2e-locks"); +const ACQUIRE_TIMEOUT_MS = 90_000; +const STALE_LOCK_MS = 300_000; +const RETRY_MS = 100; + +/** Acquire an exclusive lock shared by every worker on this machine. */ +export async function acquireGlobalLock(name: string): Promise<() => Promise> { + const path = join(LOCK_ROOT, name); + await mkdir(LOCK_ROOT, { recursive: true }); + const deadline = Date.now() + ACQUIRE_TIMEOUT_MS; + + for (;;) { + try { + await mkdir(path); + return async () => { + await rm(path, { recursive: true, force: true }); + }; + } catch (error) { + if ((error as { code?: string }).code !== "EEXIST") throw error; + } + + // A worker that crashed mid-test leaves the directory behind; only break + // it once it is older than any single test could reasonably hold it. + const info = await stat(path).catch(() => null); + if (info && Date.now() - info.mtimeMs > STALE_LOCK_MS) { + await rm(path, { recursive: true, force: true }); + continue; + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for the "${name}" global test lock`); + } + await new Promise((resolve) => setTimeout(resolve, RETRY_MS)); + } +} diff --git a/e2e/fixtures/plugin-zip.ts b/e2e/fixtures/plugin-zip.ts index 97f7711..c3841c3 100644 --- a/e2e/fixtures/plugin-zip.ts +++ b/e2e/fixtures/plugin-zip.ts @@ -1,3 +1,5 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { type APIRequestContext, expect } from "./context-path"; const BASE_URL = process.env.BASE_URL || "http://localhost:8080"; @@ -126,22 +128,12 @@ export async function installCapabilityPlugin( name: string, capabilities: string[], ): Promise { - const zip = capabilityPluginZip(name, capabilities); - - const response = await request.post(`${BASE_URL}/api/plugins/upload`, { - headers: { "Sec-Fetch-Site": "same-origin" }, - multipart: { - plugin: { - name: `${name}.zip`, - mimeType: "application/zip", - buffer: Buffer.from(zip), - }, - }, + await uploadStubPlugin(request, { + name, + version: "1.0.0", + entryPoint: "plugin.wasm", + capabilities, }); - expect( - response.ok(), - `plugin upload failed (${response.status()}): ${await response.text()}`, - ).toBeTruthy(); } /** @@ -171,18 +163,130 @@ export async function deleteCapabilityPlugins( } } -function capabilityPluginZip(name: string, capabilities: string[]): Buffer { - const manifest = JSON.stringify({ +function stubWasm(): Uint8Array { + return Uint8Array.from(atob(MINIMAL_WASM_BASE64), (c) => c.charCodeAt(0)); +} + +/** + * Upload a plugin whose manifest declares routes with explicit auth so the + * module settings page can render the per-route badge. + */ +export async function installRouteAuthPlugin( + request: APIRequestContext, + name: string, + routes: Array<{ method?: string; path: string; auth?: string; description?: string }>, +): Promise { + await uploadStubPlugin(request, { name, version: "1.0.0", entryPoint: "plugin.wasm", - capabilities, + routes, }); - const wasm = Uint8Array.from(atob(MINIMAL_WASM_BASE64), (c) => c.charCodeAt(0)); - return Buffer.from( - buildZip([ - { name: "manifest.json", data: new TextEncoder().encode(manifest) }, - { name: "plugin.wasm", data: wasm }, - ]), - ); +} + +/** + * Upload a plugin whose manifest declares host-function permissions so the + * module settings page can render the requested/granted badge per permission. + */ +export async function installPermissionPlugin( + request: APIRequestContext, + name: string, + permissions: string[], +): Promise { + await uploadStubPlugin(request, { + name, + version: "1.0.0", + entryPoint: "plugin.wasm", + permissions, + }); +} + +// uploadStubPlugin packs a manifest plus the stub wasm and POSTs it to the admin +// upload endpoint, which every capability/route/permission fixture shares. +async function uploadStubPlugin( + request: APIRequestContext, + manifest: Record, +): Promise { + const name = String(manifest.name); + const zip = buildZip([ + { name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest)) }, + { name: "plugin.wasm", data: stubWasm() }, + ]); + await uploadZip(request, `${name}.zip`, zip); +} + +/** + * Upload the real editor.markdown plugin from the core checkout so the spec + * exercises the shipped editor.html/editor.js, not a copy. The plugin is + * frontend-only, so the shared stub stands in for the WASM entry point. + */ +export async function installEditorPluginFromCore( + request: APIRequestContext, + coreDir: string, + pluginName: string, +): Promise { + const root = path.join(coreDir, "plugin-src", pluginName); + const manifest = JSON.parse( + fs.readFileSync(path.join(root, "manifest.json"), "utf8"), + ) as Record; + const encoder = new TextEncoder(); + const entries: ZipEntry[] = [ + { name: "manifest.json", data: encoder.encode(JSON.stringify(manifest)) }, + { name: "plugin.wasm", data: stubWasm() }, + ]; + for (const asset of ["editor.html", "editor.js", "editor.css"]) { + entries.push({ + name: `assets/${asset}`, + data: new Uint8Array(fs.readFileSync(path.join(root, "assets", asset))), + }); + } + await uploadZip(request, `${pluginName}.zip`, buildZip(entries)); +} + +/** + * Upload a plugin that declares per-prefix asset auth policies, plus the given + * assets (paths relative to the plugin's assets directory). Used by the asset + * auth browser spec. + */ +export async function installAssetPlugin( + request: APIRequestContext, + name: string, + policies: Array<{ path: string; auth: string }>, + assets: Record, +): Promise { + const encoder = new TextEncoder(); + const entries: ZipEntry[] = [ + { + name: "manifest.json", + data: encoder.encode( + JSON.stringify({ name, version: "1.0.0", entryPoint: "plugin.wasm", assets: policies }), + ), + }, + { name: "plugin.wasm", data: stubWasm() }, + ]; + for (const [assetPath, content] of Object.entries(assets)) { + entries.push({ name: `assets/${assetPath}`, data: encoder.encode(content) }); + } + await uploadZip(request, `${name}.zip`, buildZip(entries)); +} + +async function uploadZip( + request: APIRequestContext, + filename: string, + zip: Uint8Array, +): Promise { + const response = await request.post(`${BASE_URL}/api/plugins/upload`, { + headers: { "Sec-Fetch-Site": "same-origin" }, + multipart: { + plugin: { + name: filename, + mimeType: "application/zip", + buffer: Buffer.from(zip), + }, + }, + }); + expect( + response.ok(), + `plugin upload failed (${response.status()}): ${await response.text()}`, + ).toBeTruthy(); } diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index 6a48577..faabd1a 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -420,13 +420,6 @@ export function generatePriority(suffix?: string): TestPriority { }; } -/** - * Wait helper for animations or async operations - */ -export async function waitFor(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - /** * Generate random string of specified length */ diff --git a/e2e/global.setup.ts b/e2e/global.setup.ts index b2c50c8..b1d18e9 100644 --- a/e2e/global.setup.ts +++ b/e2e/global.setup.ts @@ -1,7 +1,7 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { expect, test as setup } from './fixtures/context-path'; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test as setup } from "./fixtures/context-path"; /** * Global setup that runs once before all tests @@ -11,79 +11,171 @@ import { expect, test as setup } from './fixtures/context-path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // E2E_AUTH_FILE lets run-e2e.sh point each concurrent run at its own file. -const authFile = process.env.E2E_AUTH_FILE ?? path.join(__dirname, '.auth/user.json'); - -setup('complete application setup and authenticate', async ({ page, request }) => { - const baseURL = process.env.BASE_URL || 'http://localhost:8080'; - - // Step 1: Check if setup is already completed - const statusResponse = await request.get(`${baseURL}/api/setup/status`); - expect(statusResponse.ok()).toBeTruthy(); - - const setupStatus = await statusResponse.json(); - - if (!setupStatus.setup_completed) { - console.log('🔧 Application setup not completed, running initial setup...'); - - // Complete initial setup - const setupResponse = await request.post(`${baseURL}/api/setup/complete`, { - headers: { - 'Sec-Fetch-Site': 'same-origin', - }, - data: { - admin_user: { - email: 'admin@e2etest.com', - username: 'admin', - password: 'TestPass123!', // Plaintext — hashed server-side - first_name: 'E2E', - last_name: 'Admin', - }, - module_settings: { - time_tracking_enabled: true, - test_management_enabled: true, - }, - }, - }); - - expect(setupResponse.ok()).toBeTruthy(); - console.log('✅ Application setup completed successfully'); - } else { - console.log('✅ Application setup already completed'); - } - - // Step 2: Login via API to get session cookie - console.log('🔐 Logging in to create authenticated session...'); - - const loginResponse = await request.post(`${baseURL}/api/auth/login`, { - data: { - email_or_username: 'admin', - password: 'TestPass123!', - remember_me: false, - }, - }); - - expect(loginResponse.ok()).toBeTruthy(); - - console.log('✅ Authentication successful'); - - // Step 3: Establish browser auth through the browser context's request - // client. It shares the page's cookie jar and network identity, unlike the - // standalone request fixture used for setup administration above. - await page.context().clearCookies(); - const browserLoginResponse = await page.context().request.post(`${baseURL}/api/auth/login`, { - data: { - email_or_username: 'admin', - password: 'TestPass123!', - remember_me: false, - }, - }); - expect(browserLoginResponse.ok()).toBeTruthy(); - await page.goto(`${baseURL}/`); - await expect(page.getByTestId('login-dialog')).not.toBeVisible({ - timeout: 10000, - }); - - fs.mkdirSync(path.dirname(authFile), { recursive: true }); - await page.context().storageState({ path: authFile }); - console.log('💾 Authentication state saved for reuse'); -}); +const authFile = + process.env.E2E_AUTH_FILE ?? path.join(__dirname, ".auth/user.json"); + +setup( + "complete application setup and authenticate", + async ({ page, request }) => { + const baseURL = process.env.BASE_URL || "http://localhost:8080"; + + // Step 1: Check if setup is already completed + const statusResponse = await request.get(`${baseURL}/api/setup/status`); + expect(statusResponse.ok()).toBeTruthy(); + + const setupStatus = await statusResponse.json(); + + if (!setupStatus.setup_completed) { + console.log( + "🔧 Application setup not completed, running initial setup...", + ); + + // Complete initial setup + const setupResponse = await request.post( + `${baseURL}/api/setup/complete`, + { + headers: { + "Sec-Fetch-Site": "same-origin", + }, + data: { + admin_user: { + email: "admin@e2etest.com", + username: "admin", + password: "TestPass123!", // Plaintext — hashed server-side + first_name: "E2E", + last_name: "Admin", + }, + module_settings: { + time_tracking_enabled: true, + test_management_enabled: true, + }, + }, + }, + ); + + expect(setupResponse.ok()).toBeTruthy(); + console.log("✅ Application setup completed successfully"); + } else { + console.log("✅ Application setup already completed"); + } + + // Step 2: Login via API to get session cookie + console.log("🔐 Logging in to create authenticated session..."); + + const loginResponse = await request.post(`${baseURL}/api/auth/login`, { + data: { + email_or_username: "admin", + password: "TestPass123!", + remember_me: false, + }, + }); + + expect(loginResponse.ok()).toBeTruthy(); + + console.log("✅ Authentication successful"); + + // Step 3: Configure outbound SMTP channel to point at Mailpit (if running). + // The initial DB seed (database.go:1216) creates a default outbound SMTP + // channel named "Notification Mail" with empty config and status='pending'. + // When Mailpit is running (run-e2e.sh sets $MAILPIT_SMTP_PORT), we PUT + // the channel config + toggle status to 'enabled'. Ad-hoc local runs may + // omit Mailpit; strict CI runs fail immediately when the env var is absent. + const mailpitSmtpPort = process.env.MAILPIT_SMTP_PORT; + if (process.env.E2E_REQUIRE_MAILPIT === "1" && !mailpitSmtpPort) { + throw new Error( + "Mailpit is required for this run, but MAILPIT_SMTP_PORT is not configured.", + ); + } + if (mailpitSmtpPort) { + console.log( + `đŸ“Ŧ Configuring SMTP channel → Mailpit at 127.0.0.1:${mailpitSmtpPort}...`, + ); + const sec = { "Sec-Fetch-Site": "same-origin" }; + const channelsResp = await request.get(`${baseURL}/api/channels`, { + headers: sec, + }); + expect(channelsResp.ok(), "list channels failed").toBeTruthy(); + const channelsBody = await channelsResp.json(); + const channels: Array<{ + id: number; + name: string; + type: string; + direction: string; + is_default?: boolean; + status: string; + description: string; + category_id?: number | null; + }> = channelsBody.data ?? channelsBody; + const smtpChannel = channels.find( + (c) => c.type === "smtp" && c.direction === "outbound", + ); + if (!smtpChannel) { + throw new Error("No outbound SMTP channel found in seeded channels"); + } + + const newConfig = { + smtp_host: "127.0.0.1", + smtp_port: Number(mailpitSmtpPort), + smtp_username: "", + smtp_password: "", + smtp_from_email: "e2e@windshift.test", + smtp_from_name: "Windshift E2E", + smtp_encryption: "none", + }; + + const updateResp = await request.put( + `${baseURL}/api/channels/${smtpChannel.id}/config`, + { + headers: sec, + data: { config: newConfig }, + }, + ); + expect( + updateResp.ok(), + `channel config update failed: ${updateResp.status()} ${await updateResp.text()}`, + ).toBeTruthy(); + + // Toggle: 'pending'/'disabled' → 'enabled'. If already enabled (re-run on a + // pre-existing DB), toggling would flip back to disabled — guard against that. + if (smtpChannel.status !== "enabled") { + const toggleResp = await request.put( + `${baseURL}/api/channels/${smtpChannel.id}/toggle`, + { headers: sec }, + ); + expect( + toggleResp.ok(), + `channel toggle failed: ${toggleResp.status()} ${await toggleResp.text()}`, + ).toBeTruthy(); + } + console.log("✅ SMTP channel configured + enabled"); + } else { + console.log( + "â„šī¸ MAILPIT_SMTP_PORT not set — skipping SMTP channel config", + ); + } + + // Step 4: Establish browser auth through the browser context's request + // client. It shares the page's cookie jar and network identity, unlike the + // standalone request fixture used for setup administration above. + await page.context().clearCookies(); + const browserLoginResponse = await page.context().request.post( + `${baseURL}/api/auth/login`, + { + data: { + email_or_username: "admin", + password: "TestPass123!", + remember_me: false, + }, + }, + ); + expect(browserLoginResponse.ok()).toBeTruthy(); + await page.goto(`${baseURL}/`); + await expect(page.getByTestId("login-dialog")).not.toBeVisible({ + timeout: 10000, + }); + + fs.mkdirSync(path.dirname(authFile), { recursive: true }); + await page.context().storageState({ path: authFile }); + console.log("💾 Authentication state saved for reuse"); + }, +); diff --git a/e2e/helpers/result-policy-reporter.mjs b/e2e/helpers/result-policy-reporter.mjs index c99c540..8d64911 100644 --- a/e2e/helpers/result-policy-reporter.mjs +++ b/e2e/helpers/result-policy-reporter.mjs @@ -1,129 +1,180 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs"; +import path from "node:path"; + +const EXPECTED_SKIP_FILES = new Map([ + ["tests/blog-portal-screenshots.spec.ts", "opt-in blog screenshot harness"], + ["tests/docs-screenshots.spec.ts", "opt-in documentation screenshot harness"], + ["tests/readme-screenshots.spec.ts", "opt-in README screenshot harness"], + ["tests/refactor-shots.spec.ts", "opt-in refactor screenshot harness"], + ["tests/admin-docs-screenshots.spec.ts", "opt-in documentation screenshot harness"], + ["tests/platform-docs-screenshots.spec.ts", "opt-in documentation screenshot harness"], + ["tests/using-windshift-docs-screenshots.spec.ts", "opt-in documentation screenshot harness"], + ["tests/work-management-docs-screenshots.spec.ts", "opt-in documentation screenshot harness"], + ["tests/playbook-screenshots.spec.ts", "opt-in playbook screenshot harness"], + ["tests/playbook-jira-screenshots.spec.ts", "opt-in playbook screenshot harness"], + ["tests/playbook-service-desk-screenshots.spec.ts", "opt-in playbook screenshot harness"], + ["tests/playbook-time-screenshots.spec.ts", "opt-in playbook screenshot harness"], +]); function normalizedPath(file) { - return String(file || '').replaceAll('\\', '/'); + return String(file || "").replaceAll("\\", "/"); +} + +export function expectedSkipReason(file) { + const normalized = normalizedPath(file); + for (const [suffix, reason] of EXPECTED_SKIP_FILES) { + if (normalized.endsWith(suffix)) return reason; + } + return ""; } function testIdentity(test) { - return { - id: test.id, - title: test.titlePath().join(' â€ē '), - file: normalizedPath(test.location.file), - line: test.location.line, - }; + return { + id: test.id, + title: test.titlePath().join(" â€ē "), + file: normalizedPath(test.location.file), + line: test.location.line, + }; } export function renderGitHubSummary(summary) { - const { counts } = summary; - const lines = [ - '## E2E result policy', - '', - '| Total | Passed | Failed | Skipped | Unexpected skips | Passed on retry |', - '| ---: | ---: | ---: | ---: | ---: | ---: |', - `| ${counts.total} | ${counts.passed} | ${counts.failed} | ${counts.skipped} | ${counts.unexpected_skips} | ${counts.passed_on_retry} |`, - ]; - const noteworthy = [ - ...summary.unexpected_skips.map((entry) => ['Unexpected skip', entry]), - ...summary.passed_on_retry.map((entry) => ['Passed on retry', entry]), - ...summary.failed_tests.map((entry) => ['Failed', entry]), - ]; - if (noteworthy.length > 0) { - lines.push('', '### Noteworthy results', ''); - for (const [kind, entry] of noteworthy) { - lines.push(`- ${kind}: \`${entry.file}:${entry.line}\` — ${entry.title}`); - } - } - return `${lines.join('\n')}\n`; + const { counts } = summary; + const lines = [ + "## E2E result policy", + "", + "| Total | Passed | Failed | Skipped | Unexpected skips | Passed on retry |", + "| ---: | ---: | ---: | ---: | ---: | ---: |", + `| ${counts.total} | ${counts.passed} | ${counts.failed} | ${counts.skipped} | ${counts.unexpected_skips} | ${counts.passed_on_retry} |`, + ]; + const noteworthy = [ + ...summary.unexpected_skips.map((entry) => ["Unexpected skip", entry]), + ...summary.passed_on_retry.map((entry) => ["Passed on retry", entry]), + ...summary.failed_tests.map((entry) => ["Failed", entry]), + ]; + if (noteworthy.length > 0) { + lines.push("", "### Noteworthy results", ""); + for (const [kind, entry] of noteworthy) { + lines.push(`- ${kind}: \`${entry.file}:${entry.line}\` — ${entry.title}`); + } + } + return `${lines.join("\n")}\n`; } export default class ResultPolicyReporter { - constructor(options = {}) { - this.outputFile = options.outputFile || 'e2e-summary.json'; - this.githubSummaryFile = options.githubSummaryFile || ''; - this.failOnUnexpectedSkip = options.failOnUnexpectedSkip === true; - this.failOnRetry = options.failOnRetry === true; - this.results = new Map(); - } + constructor(options = {}) { + this.outputFile = options.outputFile || "e2e-summary.json"; + this.githubSummaryFile = options.githubSummaryFile || ""; + this.failOnUnexpectedSkip = options.failOnUnexpectedSkip === true; + this.failOnRetry = options.failOnRetry === true; + this.results = new Map(); + } - onTestEnd(test, result) { - const entry = this.results.get(test.id) || { - ...testIdentity(test), - attempts: [], - }; - entry.attempts.push({ - retry: result.retry, - status: result.status, - duration_ms: result.duration, - }); - this.results.set(test.id, entry); - } + onTestEnd(test, result) { + const entry = this.results.get(test.id) || { + ...testIdentity(test), + expected_status: test.expectedStatus, + attempts: [], + }; + entry.attempts.push({ + retry: result.retry, + status: result.status, + duration_ms: result.duration, + }); + this.results.set(test.id, entry); + } - async onEnd(fullResult) { - const entries = [...this.results.values()].map((entry) => ({ - ...entry, - final_status: entry.attempts.at(-1)?.status || 'unknown', - })); - const skipped = entries.filter((entry) => entry.final_status === 'skipped'); - const expectedSkips = []; - const unexpectedSkips = skipped; - const passedOnRetry = entries.filter( - (entry) => - entry.final_status === 'passed' && - (entry.attempts.length > 1 || entry.attempts.some((attempt) => attempt.retry > 0)) - ); - const failed = entries.filter((entry) => - ['failed', 'timedOut', 'interrupted'].includes(entry.final_status) - ); + async onEnd(fullResult) { + const entries = [...this.results.values()].map((entry) => ({ + ...entry, + final_status: entry.attempts.at(-1)?.status || "unknown", + })); + const skipped = entries.filter((entry) => entry.final_status === "skipped"); + const expectedSkips = []; + const unexpectedSkips = []; + for (const entry of skipped) { + const reason = expectedSkipReason(entry.file); + if (reason) expectedSkips.push({ ...entry, reason }); + else unexpectedSkips.push(entry); + } + const passedOnRetry = entries.filter( + (entry) => + entry.final_status === "passed" && + (entry.attempts.length > 1 || + entry.attempts.some((attempt) => attempt.retry > 0)), + ); + // test.fail() marks a test expected to fail; Playwright reports its raw + // status as "failed", so split those out instead of counting them as + // regressions. An expected failure that passes is an unexpected success. + const expectedFailures = entries.filter( + (entry) => + entry.expected_status === "failed" && + ["failed", "timedOut", "interrupted"].includes(entry.final_status), + ); + const unexpectedPasses = entries.filter( + (entry) => + entry.expected_status === "failed" && entry.final_status === "passed", + ); + const expectedFailureIds = new Set(expectedFailures.map((entry) => entry.id)); + const failed = entries.filter( + (entry) => + !expectedFailureIds.has(entry.id) && + ["failed", "timedOut", "interrupted"].includes(entry.final_status), + ); - const counts = { - total: entries.length, - passed: entries.filter((entry) => entry.final_status === 'passed').length, - failed: failed.length, - skipped: skipped.length, - expected_skips: expectedSkips.length, - unexpected_skips: unexpectedSkips.length, - passed_on_retry: passedOnRetry.length, - }; - const policyFailed = - (this.failOnUnexpectedSkip && unexpectedSkips.length > 0) || - (this.failOnRetry && passedOnRetry.length > 0); - const summary = { - schema_version: 1, - generated_at: new Date().toISOString(), - playwright_status: fullResult.status, - policy_status: policyFailed ? 'failed' : 'passed', - policy: { - fail_on_unexpected_skip: this.failOnUnexpectedSkip, - fail_on_retry: this.failOnRetry, - }, - counts, - unexpected_skips: unexpectedSkips, - expected_skips: expectedSkips, - passed_on_retry: passedOnRetry, - failed_tests: failed, - }; + const counts = { + total: entries.length, + passed: entries.filter((entry) => entry.final_status === "passed").length, + failed: failed.length + unexpectedPasses.length, + skipped: skipped.length, + expected_skips: expectedSkips.length, + unexpected_skips: unexpectedSkips.length, + passed_on_retry: passedOnRetry.length, + expected_failures: expectedFailures.length, + }; + const policyFailed = + (this.failOnUnexpectedSkip && unexpectedSkips.length > 0) || + (this.failOnRetry && passedOnRetry.length > 0); + const summary = { + schema_version: 1, + generated_at: new Date().toISOString(), + playwright_status: fullResult.status, + policy_status: policyFailed ? "failed" : "passed", + policy: { + fail_on_unexpected_skip: this.failOnUnexpectedSkip, + fail_on_retry: this.failOnRetry, + }, + counts, + unexpected_skips: unexpectedSkips, + expected_skips: expectedSkips, + passed_on_retry: passedOnRetry, + failed_tests: [...failed, ...unexpectedPasses], + expected_failures: expectedFailures, + }; - fs.mkdirSync(path.dirname(path.resolve(this.outputFile)), { - recursive: true, - }); - fs.writeFileSync(this.outputFile, `${JSON.stringify(summary, null, 2)}\n`); - if (this.githubSummaryFile) { - fs.appendFileSync(this.githubSummaryFile, renderGitHubSummary(summary)); - } + fs.mkdirSync(path.dirname(path.resolve(this.outputFile)), { + recursive: true, + }); + fs.writeFileSync(this.outputFile, `${JSON.stringify(summary, null, 2)}\n`); + if (this.githubSummaryFile) { + fs.appendFileSync(this.githubSummaryFile, renderGitHubSummary(summary)); + } - console.log( - `E2E result policy: ${counts.unexpected_skips} unexpected skip(s), ` + - `${counts.expected_skips} expected skip(s), ${counts.passed_on_retry} pass-on-retry test(s)` - ); - for (const entry of unexpectedSkips) { - console.error(`Unexpected skip: ${entry.file}:${entry.line} — ${entry.title}`); - } - for (const entry of passedOnRetry) { - console.warn(`Passed on retry: ${entry.file}:${entry.line} — ${entry.title}`); - } + console.log( + `E2E result policy: ${counts.unexpected_skips} unexpected skip(s), ` + + `${counts.expected_skips} expected skip(s), ${counts.passed_on_retry} pass-on-retry test(s)`, + ); + for (const entry of unexpectedSkips) { + console.error( + `Unexpected skip: ${entry.file}:${entry.line} — ${entry.title}`, + ); + } + for (const entry of passedOnRetry) { + console.warn( + `Passed on retry: ${entry.file}:${entry.line} — ${entry.title}`, + ); + } - if (policyFailed) return { status: 'failed' }; - return undefined; - } + if (policyFailed) return { status: "failed" }; + return undefined; + } } diff --git a/e2e/helpers/result-policy-reporter.test.mjs b/e2e/helpers/result-policy-reporter.test.mjs index 954159e..72b7e49 100644 --- a/e2e/helpers/result-policy-reporter.test.mjs +++ b/e2e/helpers/result-policy-reporter.test.mjs @@ -1,111 +1,175 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; -import ResultPolicyReporter, { renderGitHubSummary } from './result-policy-reporter.mjs'; +import ResultPolicyReporter, { + expectedSkipReason, + renderGitHubSummary, +} from "./result-policy-reporter.mjs"; -function testCase(id, file, title) { - return { - id, - location: { file, line: 12 }, - titlePath: () => ['chromium', title], - }; +function testCase(id, file, title, expectedStatus = "passed") { + return { + id, + location: { file, line: 12 }, + titlePath: () => ["chromium", title], + expectedStatus, + }; } function result(status, retry = 0) { - return { status, retry, duration: 10 }; + return { status, retry, duration: 10 }; } function reporterOptions(outputFile, overrides = {}) { - return { - outputFile, - failOnUnexpectedSkip: true, - failOnRetry: false, - ...overrides, - }; + return { + outputFile, + failOnUnexpectedSkip: true, + failOnRetry: false, + ...overrides, + }; } -function silenceReporterOutput(t) { - const original = { - error: console.error, - log: console.log, - warn: console.warn, - }; - console.error = () => {}; - console.log = () => {}; - console.warn = () => {}; - t.after(() => { - console.error = original.error; - console.log = original.log; - console.warn = original.warn; - }); -} +test("expected skip allowlist is narrow and documented", () => { + assert.match( + expectedSkipReason("/repo/e2e/tests/blog-portal-screenshots.spec.ts"), + /screenshot/, + ); + assert.equal(expectedSkipReason("/repo/e2e/tests/setup.spec.ts"), ""); + assert.equal( + expectedSkipReason("/repo/e2e/tests/mobile-surface.spec.ts"), + "", + ); +}); + +test("unexpected skips fail policy and are persisted with location", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-policy-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const outputFile = path.join(tempDir, "summary.json"); + const reporter = new ResultPolicyReporter(reporterOptions(outputFile)); + reporter.onTestEnd( + testCase( + "skip-1", + "/repo/e2e/tests/mobile-surface.spec.ts", + "opens an assigned item", + ), + result("skipped"), + ); + + assert.deepEqual(await reporter.onEnd({ status: "passed" }), { + status: "failed", + }); + const summary = JSON.parse(fs.readFileSync(outputFile, "utf8")); + assert.equal(summary.counts.unexpected_skips, 1); + assert.equal(summary.unexpected_skips[0].line, 12); +}); + +test("expected skips remain visible without failing policy", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-policy-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const outputFile = path.join(tempDir, "summary.json"); + const reporter = new ResultPolicyReporter(reporterOptions(outputFile)); + reporter.onTestEnd( + testCase( + "skip-2", + "/repo/e2e/tests/docs-screenshots.spec.ts", + "captures documentation", + ), + result("skipped"), + ); + + assert.equal(await reporter.onEnd({ status: "passed" }), undefined); + const summary = JSON.parse(fs.readFileSync(outputFile, "utf8")); + assert.equal(summary.counts.expected_skips, 1); + assert.equal(summary.expected_skips[0].reason.includes("screenshot"), true); +}); -test('unexpected skips fail policy and are persisted with location', async (t) => { - silenceReporterOutput(t); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-policy-')); - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); - const outputFile = path.join(tempDir, 'summary.json'); - const reporter = new ResultPolicyReporter(reporterOptions(outputFile)); - reporter.onTestEnd( - testCase('skip-1', '/repo/e2e/tests/mobile-surface.spec.ts', 'opens an assigned item'), - result('skipped') - ); +test("pass-on-retry tests are identified and can be enforced", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-policy-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const outputFile = path.join(tempDir, "summary.json"); + const reporter = new ResultPolicyReporter( + reporterOptions(outputFile, { + failOnUnexpectedSkip: false, + failOnRetry: true, + }), + ); + const flaky = testCase( + "retry-1", + "/repo/e2e/tests/items.spec.ts", + "creates an item", + ); + reporter.onTestEnd(flaky, result("failed", 0)); + reporter.onTestEnd(flaky, result("passed", 1)); - assert.deepEqual(await reporter.onEnd({ status: 'passed' }), { - status: 'failed', - }); - const summary = JSON.parse(fs.readFileSync(outputFile, 'utf8')); - assert.equal(summary.counts.unexpected_skips, 1); - assert.equal(summary.unexpected_skips[0].line, 12); + assert.deepEqual(await reporter.onEnd({ status: "passed" }), { + status: "failed", + }); + const summary = JSON.parse(fs.readFileSync(outputFile, "utf8")); + assert.equal(summary.counts.passed_on_retry, 1); + assert.deepEqual( + summary.passed_on_retry[0].attempts.map((attempt) => attempt.status), + ["failed", "passed"], + ); + assert.match(renderGitHubSummary(summary), /Passed on retry/); + assert.match(renderGitHubSummary(summary), /items\.spec\.ts:12/); }); -test('pass-on-retry tests are identified and can be enforced', async (t) => { - silenceReporterOutput(t); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-policy-')); - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); - const outputFile = path.join(tempDir, 'summary.json'); - const reporter = new ResultPolicyReporter( - reporterOptions(outputFile, { - failOnUnexpectedSkip: false, - failOnRetry: true, - }) - ); - const flaky = testCase('retry-1', '/repo/e2e/tests/items.spec.ts', 'creates an item'); - reporter.onTestEnd(flaky, result('failed', 0)); - reporter.onTestEnd(flaky, result('passed', 1)); +test("expected failures are separated from regressions", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-policy-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const outputFile = path.join(tempDir, "summary.json"); + const reporter = new ResultPolicyReporter( + reporterOptions(outputFile, { failOnUnexpectedSkip: false }), + ); + // test.fail() reports a raw "failed" status; it is a known defect, not a + // regression. A test.fail() that passes is an unexpected success. + reporter.onTestEnd( + testCase( + "xfail-1", + "/repo/e2e/tests/mobile-surface.spec.ts", + "known mobile defect", + "failed", + ), + result("failed"), + ); + reporter.onTestEnd( + testCase( + "xpass-1", + "/repo/e2e/tests/mobile-surface.spec.ts", + "known mobile defect", + "failed", + ), + result("passed"), + ); - assert.deepEqual(await reporter.onEnd({ status: 'passed' }), { - status: 'failed', - }); - const summary = JSON.parse(fs.readFileSync(outputFile, 'utf8')); - assert.equal(summary.counts.passed_on_retry, 1); - assert.deepEqual( - summary.passed_on_retry[0].attempts.map((attempt) => attempt.status), - ['failed', 'passed'] - ); - assert.match(renderGitHubSummary(summary), /Passed on retry/); - assert.match(renderGitHubSummary(summary), /items\.spec\.ts:12/); + assert.equal(await reporter.onEnd({ status: "failed" }), undefined); + const summary = JSON.parse(fs.readFileSync(outputFile, "utf8")); + assert.equal(summary.counts.expected_failures, 1); + assert.equal(summary.expected_failures[0].id, "xfail-1"); + assert.equal(summary.counts.failed, 1); + assert.deepEqual( + summary.failed_tests.map((entry) => entry.id), + ["xpass-1"], + ); }); -test('appends compact counts to the GitHub job summary', async (t) => { - silenceReporterOutput(t); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-policy-')); - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); - const outputFile = path.join(tempDir, 'summary.json'); - const githubSummaryFile = path.join(tempDir, 'github-summary.md'); - const reporter = new ResultPolicyReporter({ - ...reporterOptions(outputFile), - githubSummaryFile, - }); - reporter.onTestEnd( - testCase('pass-1', '/repo/e2e/tests/items.spec.ts', 'creates an item'), - result('passed') - ); +test("appends compact counts to the GitHub job summary", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-policy-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const outputFile = path.join(tempDir, "summary.json"); + const githubSummaryFile = path.join(tempDir, "github-summary.md"); + const reporter = new ResultPolicyReporter({ + ...reporterOptions(outputFile), + githubSummaryFile, + }); + reporter.onTestEnd( + testCase("pass-1", "/repo/e2e/tests/items.spec.ts", "creates an item"), + result("passed"), + ); - await reporter.onEnd({ status: 'passed' }); - const markdown = fs.readFileSync(githubSummaryFile, 'utf8'); - assert.match(markdown, /\| 1 \| 1 \| 0 \| 0 \| 0 \| 0 \|/); + await reporter.onEnd({ status: "passed" }); + const markdown = fs.readFileSync(githubSummaryFile, "utf8"); + assert.match(markdown, /\| 1 \| 1 \| 0 \| 0 \| 0 \| 0 \|/); }); diff --git a/e2e/package-lock.json b/e2e/package-lock.json index f5e09bc..d9c4ad1 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -14,13 +14,13 @@ } }, "node_modules/@axe-core/playwright": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", - "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.13.0" + "axe-core": "~4.12.1" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -43,9 +43,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { diff --git a/e2e/package.json b/e2e/package.json index 8739607..a530b85 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,21 +1,21 @@ { - "name": "windshift-e2e", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "test": "bun --bun playwright test", - "test:headed": "bun --bun playwright test --headed", - "test:debug": "bun --bun playwright test --debug", - "test:ui": "bun --bun playwright test --ui", - "test:report": "bun --bun playwright show-report", - "test:suite-boundaries": "bun scripts/check-suite-boundaries.mjs", - "test:result-policy": "bun test helpers/result-policy-reporter.test.mjs", - "test:selector-policy": "bun test helpers/selector-policy.test.mjs && bun scripts/check-stable-selectors.mjs" - }, - "devDependencies": { - "@axe-core/playwright": "^4.12.1", - "@playwright/test": "^1.49.0", - "typescript": "^5.7.0" - } + "name": "windshift-e2e", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "bun --bun playwright test", + "test:headed": "bun --bun playwright test --headed", + "test:debug": "bun --bun playwright test --debug", + "test:ui": "bun --bun playwright test --ui", + "test:report": "bun --bun playwright show-report", + "test:suite-boundaries": "bun scripts/check-suite-boundaries.mjs", + "test:result-policy": "bun test helpers/result-policy-reporter.test.mjs", + "test:selector-policy": "bun test helpers/selector-policy.test.mjs && bun scripts/check-stable-selectors.mjs" + }, + "devDependencies": { + "@axe-core/playwright": "^4.12.1", + "@playwright/test": "^1.49.0", + "typescript": "^5.7.0" + } } diff --git a/e2e/pages/milestone.page.ts b/e2e/pages/milestone.page.ts index e20adc6..b9a89fb 100644 --- a/e2e/pages/milestone.page.ts +++ b/e2e/pages/milestone.page.ts @@ -8,7 +8,7 @@ import { expect, type Page } from '../fixtures/context-path'; * Form field selectors: * - name: #milestone-name (native input) * - target_date: #milestone-target-date (native date input) - * - description: #milestone-description (textarea component) + * - description: [data-testid="milestone-description-editor"] (Milkdown editor) * - status: #milestone-status-picker (BasePicker trigger) * Category is optional and skipped for test simplicity. */ @@ -22,7 +22,7 @@ export class MilestonePage { readonly modal = 'div[role="dialog"]'; readonly nameInput = '#milestone-name'; readonly targetDateInput = '#milestone-target-date'; - readonly descriptionInput = '#milestone-description'; + readonly descriptionInput = '[data-testid="milestone-description-editor"]'; readonly statusPicker = '#milestone-status-picker'; async gotoGlobal() { @@ -51,7 +51,13 @@ export class MilestonePage { await this.page.locator(this.targetDateInput).fill(data.target_date); } if (data.description) { - await this.page.locator(this.descriptionInput).fill(data.description); + // Milestones edit their description in a Milkdown (ProseMirror) editor. + // Wait for the lazily loaded editor, then type into it so ProseMirror + // sees real input events. + const editor = this.page.locator(`${this.descriptionInput} .ProseMirror`); + await editor.waitFor({ state: 'visible', timeout: 10000 }); + await editor.click(); + await editor.pressSequentially(data.description); } if (data.status) { await this.selectStatus(data.status); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 53e8a73..e8e4118 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,70 +1,224 @@ -import { defineConfig, devices } from '@playwright/test'; - -const authFile = process.env.E2E_AUTH_FILE ?? '.auth/user.json'; -const outputDir = process.env.E2E_OUTPUT_DIR ?? 'test-results'; -const reportDir = process.env.E2E_REPORT_DIR ?? 'playwright-report'; -const summaryPath = process.env.E2E_SUMMARY_PATH ?? 'e2e-summary.json'; -const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; -const freshSetupPhase = process.env.E2E_FRESH_SETUP_PHASE ?? ''; +import { defineConfig, devices } from "@playwright/test"; + +// Per-run path overrides let `run-e2e.sh` isolate concurrent invocations. +// Defaults preserve the old behavior for ad-hoc `bun --bun playwright test` runs. +const AUTH_FILE = process.env.E2E_AUTH_FILE ?? ".auth/user.json"; +const OUTPUT_DIR = process.env.E2E_OUTPUT_DIR ?? "test-results"; +const REPORT_DIR = process.env.E2E_REPORT_DIR ?? "playwright-report"; +const SUMMARY_PATH = process.env.E2E_SUMMARY_PATH ?? "e2e-summary.json"; +const FRESH_SETUP_PHASE = process.env.E2E_FRESH_SETUP_PHASE ?? ""; +const E2E_BROWSER = process.env.E2E_BROWSER ?? "chromium"; +const CRITICAL_BROWSER_MATRIX = process.env.E2E_CRITICAL_BROWSER_MATRIX === "1"; +const LIVE_JIRA_IMPORT = process.env.E2E_LIVE_JIRA_IMPORT === "1"; +const CHROMIUM_EXECUTABLE_PATH = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; +const LAUNCH_OPTIONS = CHROMIUM_EXECUTABLE_PATH + ? { executablePath: CHROMIUM_EXECUTABLE_PATH } + : undefined; +const VIDEO_MODE = CHROMIUM_EXECUTABLE_PATH ? "off" : "retain-on-failure"; + +const browserDevices = { + chromium: devices["Desktop Chrome"], + firefox: devices["Desktop Firefox"], + webkit: devices["Desktop Safari"], +} as const; + +type MatrixBrowser = keyof typeof browserDevices; + +function matrixBrowser(): MatrixBrowser { + if (E2E_BROWSER in browserDevices) return E2E_BROWSER as MatrixBrowser; + throw new Error( + `E2E_BROWSER must be chromium, firefox, or webkit; got ${E2E_BROWSER}`, + ); +} + +function browserUse(browser: MatrixBrowser, authenticated = true) { + return { + ...browserDevices[browser], + reducedMotion: "reduce" as const, + storageState: authenticated ? AUTH_FILE : { cookies: [], origins: [] }, + ...(browser === "chromium" && LAUNCH_OPTIONS + ? { launchOptions: LAUNCH_OPTIONS } + : {}), + }; +} + +const selectedBrowser = matrixBrowser(); +const criticalGrep = CRITICAL_BROWSER_MATRIX ? /@critical-browser/ : /$a/; +const globalSetupMatch = /.*\/global\.setup\.ts/; +const noTests = /$a/; const freshSetupTests = /.*\/fresh-setup(?:-restart)?\.spec\.ts/; +const liveJiraImportTests = /.*\/jira-import(?:-xray)?-live\.spec\.ts/; +// Wire-mail specs need the WIRE_MAILLOOP=1 runner (local Stalwart + TLS +// skip-verify); they are excluded from every other run, including CI. +const wireMailLoopTests = /.*\/email-portal-wire-loop\.spec\.ts/; +const wireMailLoop = (process.env.WIRE_MAILLOOP ?? "") === "1"; +const standardTestIgnore = LIVE_JIRA_IMPORT + ? [freshSetupTests, /.*\/website-api-docs\.spec\.ts/, ...(wireMailLoop ? [] : [wireMailLoopTests])] + : [ + freshSetupTests, + liveJiraImportTests, + /.*\/website-api-docs\.spec\.ts/, + ...(wireMailLoop ? [] : [wireMailLoopTests]), + ]; +/** + * Playwright configuration for Windshift E2E tests + * See https://playwright.dev/docs/test-configuration + */ export default defineConfig({ - testDir: './tests', - outputDir, - fullyParallel: true, - forbidOnly: true, - retries: 0, - workers: process.env.CI ? 2 : 4, - reporter: [ - ['html', { outputFolder: reportDir }], - ['list'], - [ - './helpers/result-policy-reporter.mjs', - { - outputFile: summaryPath, - githubSummaryFile: process.env.GITHUB_STEP_SUMMARY, - failOnUnexpectedSkip: true, - failOnRetry: true, - }, - ], - ...(process.env.CI ? [['github']] : []), - ], - use: { - ...devices['Desktop Chrome'], - baseURL: process.env.BASE_URL || 'http://localhost:8080', - storageState: authFile, - reducedMotion: 'reduce', - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: chromiumExecutablePath ? 'off' : 'retain-on-failure', - actionTimeout: 10_000, - ...(chromiumExecutablePath - ? { launchOptions: { executablePath: chromiumExecutablePath } } - : {}), - }, - timeout: 60_000, - expect: { timeout: 10_000 }, - projects: [ - { - name: 'setup', - testMatch: /.*\/global\.setup\.ts/, - testDir: '.', - use: { storageState: { cookies: [], origins: [] } }, - }, - { - name: 'chromium', - dependencies: ['setup'], - testIgnore: freshSetupTests, - }, - { - name: 'fresh-setup', - testMatch: freshSetupPhase === 'initial' ? /.*\/fresh-setup\.spec\.ts/ : /$a/, - use: { storageState: { cookies: [], origins: [] } }, - }, - { - name: 'fresh-setup-restart', - testMatch: freshSetupPhase === 'restart' ? /.*\/fresh-setup-restart\.spec\.ts/ : /$a/, - use: { storageState: { cookies: [], origins: [] } }, - }, - ], + testDir: "./tests", + outputDir: OUTPUT_DIR, + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + + /* Fail once and retain the evidence instead of spending CI time on retries. */ + retries: 0, + + /* WebKit throttles animation frames for background pages on Linux, which can + * prevent actionability checks from observing a stable element. Keep its CI + * matrix job to one page; other browsers retain parallel coverage. */ + workers: process.env.CI ? (selectedBrowser === "webkit" ? 1 : 2) : 4, + + /* Reporter to use */ + reporter: [ + ["html", { outputFolder: REPORT_DIR }], + ["list"], + [ + "./helpers/result-policy-reporter.mjs", + { + outputFile: SUMMARY_PATH, + githubSummaryFile: process.env.GITHUB_STEP_SUMMARY, + failOnUnexpectedSkip: process.env.E2E_FAIL_ON_UNEXPECTED_SKIP === "1", + failOnRetry: process.env.E2E_FAIL_ON_RETRY === "1", + }, + ], + ...(process.env.E2E_SCREENSHOTS === "1" + ? [["./helpers/screenshot-reporter.ts"]] + : []), + ...(process.env.CI ? [["github"]] : []), + ], + + /* Shared settings for all the projects below */ + use: { + /* Base URL to use in actions like `await page.goto('/')` */ + baseURL: process.env.BASE_URL || "http://localhost:8080", + + /* Keep the first failure trace; retries are disabled. */ + trace: "retain-on-failure", + + /* Capture an end-of-test screenshot when the documentation flag is set; + * otherwise only on failure. The screenshot-reporter (registered above) + * copies the resulting PNGs into e2e/screenshots//.png. */ + screenshot: process.env.E2E_SCREENSHOTS === "1" ? "on" : "only-on-failure", + + /* Video on failure */ + video: VIDEO_MODE, + + /* Maximum time each action can take */ + actionTimeout: 10000, + }, + + /* Global timeout for each test */ + timeout: 60000, + + /* Timeout for expect assertions */ + expect: { + timeout: 10000, + }, + + /* Configure projects for major browsers */ + projects: [ + // The broad suite remains Chromium-only. Critical projects have a + // matching setup dependency so Firefox/WebKit do not need Chromium + // installed merely to create their authenticated storage state. + { + name: "setup-chromium", + testMatch: + !CRITICAL_BROWSER_MATRIX || selectedBrowser === "chromium" + ? globalSetupMatch + : noTests, + testDir: ".", + use: browserUse("chromium", false), + }, + { + name: "setup-firefox", + testMatch: + CRITICAL_BROWSER_MATRIX && selectedBrowser === "firefox" + ? globalSetupMatch + : noTests, + testDir: ".", + use: browserUse("firefox", false), + }, + { + name: "setup-webkit", + testMatch: + CRITICAL_BROWSER_MATRIX && selectedBrowser === "webkit" + ? globalSetupMatch + : noTests, + testDir: ".", + use: browserUse("webkit", false), + }, + + { + name: "chromium", + testIgnore: standardTestIgnore, + use: browserUse("chromium"), + dependencies: ["setup-chromium"], + }, + + { + name: "chromium-critical", + grep: criticalGrep, + testIgnore: standardTestIgnore, + use: browserUse("chromium"), + dependencies: ["setup-chromium"], + }, + { + name: "firefox-critical", + grep: criticalGrep, + testIgnore: standardTestIgnore, + use: browserUse("firefox"), + dependencies: ["setup-firefox"], + }, + { + name: "webkit-critical", + grep: criticalGrep, + testIgnore: standardTestIgnore, + use: browserUse("webkit"), + dependencies: ["setup-webkit"], + }, + + { + name: "fresh-setup", + testMatch: + FRESH_SETUP_PHASE === "initial" ? /.*\/fresh-setup\.spec\.ts/ : /$a/, + retries: 0, + use: browserUse(selectedBrowser, false), + }, + + { + name: "fresh-setup-restart", + testMatch: + FRESH_SETUP_PHASE === "restart" + ? /.*\/fresh-setup-restart\.spec\.ts/ + : /$a/, + retries: 0, + use: browserUse(selectedBrowser, false), + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'cd ../../core && ./windshift -db e2e-test.db -p 8080', + // url: 'http://localhost:8080', + // reuseExistingServer: !process.env.CI, + // timeout: 120 * 1000, + // stdout: 'ignore', + // stderr: 'pipe', + // }, }); diff --git a/e2e/stable-selector-exceptions.json b/e2e/stable-selector-exceptions.json index 361b4b9..3432e0f 100644 --- a/e2e/stable-selector-exceptions.json +++ b/e2e/stable-selector-exceptions.json @@ -1,530 +1,545 @@ { - "schema_version": 1, - "exceptions": { - "fixtures/action-editor-helpers.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "1643d2e6b756cdf0098dd771987ff5c58852738b940c243984cb37b3f0b8825e" - }, - "fixtures/auth.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 4, - "fingerprint": "703f1e51112a0b326f7a17eb9b49775982b85bea8707494343cf0a8e83093595" - }, - "pages/admin.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "7d87a8a2899e30cbb991dea8d86e921a4de83b3d33b491638008ac2ad8426a88" - }, - "pages/board.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 11, - "fingerprint": "b12efe85d5a6bb716d4e0db7b741b9bf8699667836d273c8b04b48f23498436d" - }, - "pages/calendar.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "8a9b02f5a11f0432738acbb1b69ee156511fc2eee9c4729b5135e2928e6991dc" - }, - "pages/custom-fields.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 15, - "fingerprint": "4e18cd0d4feaa01a519620d532319cb108c1f6a8123df0b222061aea970822d0" - }, - "pages/group.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 21, - "fingerprint": "c6caf0840047da16c809d979c50bf38d5577e36cb727ee717cbabd6ef88dc856" - }, - "pages/item-links.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 7, - "fingerprint": "46f431777d54f7a268654e98688df287929023314c06d2a9c6aa76f4950c7a1c" - }, - "pages/item.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 27, - "fingerprint": "c05fe714d8f1cc92def796ffdb3f8bb2703826c936a29c847b250544bebd7511" - }, - "pages/iteration.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 18, - "fingerprint": "fa7ad943ee3ffb004d9ecd81df6d8c3b120790162c9d973bb8d9920d2f7f1d4a" - }, - "pages/knowledge.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "f5b50299f61a8b80bdc6f4d3a103ac9ecc44685f86ca491193cb13949151e51f" - }, - "pages/list-view.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 7, - "fingerprint": "6c810e0f3d212d93825dae95b11cebb41b306004068542234af4363fa10da36f" - }, - "pages/login.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 11, - "fingerprint": "00eb84d674163f4735716fcf97c9a29f3f78fe74c23ce3c6bb46a2daba887275" - }, - "pages/milestone.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 15, - "fingerprint": "d53e50acdd278430b9f35d7be808157ba72567ea320a72e11b549227244c8b21" - }, - "pages/priority-manager.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 8, - "fingerprint": "b8b1c9bbef0110a893ea98998abdc2200caf0f3c86ebd2b45a8b12c088f262ba" - }, - "pages/team-oncall.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 8, - "fingerprint": "7cdc54cb7a16e1e2bf4f245181ac29d3f01ae1ad87db7f637a68cf9f59967594" - }, - "pages/teams.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 19, - "fingerprint": "1239d9843e806e3158ce2adedba8201bf1072a0cb55ed9480071c8f646ac7509" - }, - "pages/time-tracking.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 25, - "fingerprint": "29931fd8e547df525f2ed0303d91bb60483b1123153e1c12706fadac424b5024" - }, - "pages/user.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 34, - "fingerprint": "ac6fd5df5464b9cd74d039f2bf5dd26bfa2627d4db10891da142c99f49a484b8" - }, - "pages/workspace-settings.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 16, - "fingerprint": "d2f67918fce77540b761f6baaff6a6bb7eca531a128e563394007249da7c2104" - }, - "pages/workspace.page.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 17, - "fingerprint": "8e70195d8650f822559d865af386e007a53d194bd438040798407346e41900a1" - }, - "tests/action-templates-editor-shape.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "81a691706e2efec37dfc2ac6ece148bef7d9e5410a7e77e08d3536b0f9839287" - }, - "tests/action-trigger-node-config.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "a1bed2c8c7e301b57e90929ecbb1777bb1f5e76149ec0796fccf1cf633c963ab" - }, - "tests/admin-settings.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "d4800e15216f7bbf3ccb72fbd35927d2bc8b0241ed3cf7147eaefa807b439066" - }, - "tests/agent-token-revoke.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 4, - "fingerprint": "3d7af97c7414a1d68fff0b0e125d51bad5ef6d1e31dacc7b682886f4c01b1e96" - }, - "tests/ai-chat-availability.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "1888240c4f461e777c89595f765c6a7946626502ddd68368afcdf00a026bbe85" - }, - "tests/authentication.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "f604a63aee8cd3bec98de84ada4ddf49d67ae8b26e8bf06fb92bd080ef7ba0a9" - }, - "tests/blog-portal-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "210ae06a2ea3637fcc760e030e69c0768188a787d427f63473cc5178bd72adf1" - }, - "tests/board-bubble-sort.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 4, - "fingerprint": "1c9a95b83611908271e5874d48de22c8be5df1ce11a1988deb67b6a1b073d189" - }, - "tests/board-swimlane-quick-add-types.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "ff5569e3b73fe27ca807cff1b468e57ead9dddbfbd32d38f3d3cfe7f433efc35" - }, - "tests/board-view.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "15f859a562711ebade35f346e8e8bb954b2bfc3ca0a0883e52809141c5cabfd7" - }, - "tests/button-smoke.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 9, - "fingerprint": "eeaee9edc3bf39f4ddefd166a51fe138cc982ba55d5f93dfa57993b5c5250dfd" - }, - "tests/chat-action-context.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 4, - "fingerprint": "2b69acbb7b2101b6c93eea31b261cc65ab5ddd130aa348be92af375e1ad66a6c" - }, - "tests/collaboration.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "ce0be3c5cf5fc951f7582725b5b57d1e0ebbcdaff165ed90aad50adbfee34d67" - }, - "tests/create-modal-asset-custom-field.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "7f275e4027dbcbb402f57a0d50700ee69ec8c9943bcff10a49842cfb8f054d18" - }, - "tests/create-modal-workspace-switch.spec.ts": { - "reason": "The workspace chip renders one shared option test ID with no per-workspace ID, so the option is disambiguated by its workspace key text.", - "violations": 1, - "fingerprint": "b9e3e25904b9032d5ae0434f52a42a7b0ef03af2e66fff55937d4639d39945eb" - }, - "tests/critical-browser-page.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "d36a73f373b9ba6ffd7184888885c0d30db771caf3e1de28893d3d867d7c639e" - }, - "tests/docs-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - }, - "tests/editor-scroll-audit.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "b41cedbb3b3b97d91cfdb206da8128f1b7f7dea94d970d8b9a26c5a7e95eedfc" - }, - "tests/email-templates.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 11, - "fingerprint": "0b0e0cda1b8e0515d08b67f0beeaaf364ad8c2ccba2384237de7ad1ecf7b9f34" - }, - "tests/fresh-setup-restart.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "e41ac6435a3d78c20e5985fc8093de750aea0120a07ffa3732e83175b258a5b6" - }, - "tests/global-permissions.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 49, - "fingerprint": "222ea2ad882fb0e5aa447a56fb9358e1cc6ce8d1e66ca9aa7b91121f3b702fc9" - }, - "tests/global-search.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "0c542a24110f22ccde322d4625c5bcf5b2561feab2a74c4c739090f87cc29454" - }, - "tests/groups.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "07b1e2eaa6ec7e06db478c4ae738c33bc06cc14cabdccd5098d519cbb5c9a64c" - }, - "tests/item-detail-sse-live.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "ad7fb4f153213d784154c5e87bc363ead891d61a16de37275f4c4833ea0f21a6" - }, - "tests/item-links.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "746938c879071a71f9f894a4f8f38bafe2266572304f2f32446b74ac70dcd9e0" - }, - "tests/item-modal-comment-escape.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "8d05e4fe7c679f7ee26fdabbf0b4f670d336fe13f83edd66f3b632e65beaa20e" - }, - "tests/item-templates.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 8, - "fingerprint": "d4298daa763813c1bd777885949274b600d66142523bcd3def17b77f35fec587" - }, - "tests/items.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 12, - "fingerprint": "6b756f08f876710489e7827cb1530ec5be07e81b7331d547e7020f76957aa54e" - }, - "tests/iterations.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 6, - "fingerprint": "f9a468c3f60836eeac70891469818ea92e0dd9a4e0303a0dfaa5c0d408a4c3cc" - }, - "tests/knowledge-pages.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "54d8d74e0b71b5fdf8f6df4d0d70ae88e8869fa12c0d49b941a43093b034dd24" - }, - "tests/milestones.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 10, - "fingerprint": "c388b7e38b6359bf30fed622160376db9016ec3d210c1a33cb19e7df78617959" - }, - "tests/mobile-surface.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 11, - "fingerprint": "8ad501d940d34cd2489eab309edf0f66f22e582dc6f856ca7a2d75b39718b12b" - }, - "tests/notifications.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 3, - "fingerprint": "099c9f024a0b7ba23943d2870e98f2bb1e534d68f66bc4353f2e68ce3ea376df" - }, - "tests/page-labels.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "558523c2f8bb5d19434b6c053fb5e6defafb3a82693b9f77bb984c86629059af" - }, - "tests/page-links.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 9, - "fingerprint": "b5622caf33271c6a1dab3251e5b76519f2474ea5459a302429f8b5e2f3c248c5" - }, - "tests/page-title-layout.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "d5c939510d6a5b328c5c291455d415a5ac3d9f5c2f74dc917f0d4dc577c20fe6" - }, - "tests/pages-hardbreak-br.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "5713090de8894864cad6fa1cf3b175228d8da071957470f7d00f01336b8d399d" - }, - "tests/pages-history.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "d319b2431abd1917336b5f9c6555abebb07d7d7233ff69fc7fe54c5758bb259b" - }, - "tests/pages-print.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 4, - "fingerprint": "335957669e47c800ffe5097db7872c9edd1d3e4731d01e0206051d1d30cff920" - }, - "tests/portal-channel-toggle.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 6, - "fingerprint": "ccbe4caed843b8b748dd03e2fcdc9956e3ced035c9f5ea5cefa4282de2035471" - }, - "tests/portal-draft-resume.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "017581fe241ee670941c621aa2009a37a2cd76f7b04159fda5fe8bc03f3b8b57" - }, - "tests/portal-draft-schema-change.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "017581fe241ee670941c621aa2009a37a2cd76f7b04159fda5fe8bc03f3b8b57" - }, - "tests/admin-integrations-providers.spec.ts": { - "reason": "Provider modals expose no stable container test id; role=dialog scoping is the exact mount point. Replace when dialogs grow stable test ids.", - "violations": 2, - "fingerprint": "f61a90914f27f37ce10fba58d32e5701189922457b8bc181736b36a16f08f046" - }, - "tests/admin-llm-connections.spec.ts": { - "reason": "Model picker options carry only data-option-id attributes inside the shared picker list; no per-option test id exists yet.", - "violations": 3, - "fingerprint": "42909a0d714adbbab0e58d85c68ff8aa00dbafccb7018d716d4b3cf53f777cac" - }, - "tests/admin-oauth-clients.spec.ts": { - "reason": "Scope inputs and modals lack stable test ids; role=dialog and nested input scoping is the current contract.", - "violations": 2, - "fingerprint": "835841f811c5b7e881942b31ac17351917cb338c9b4e58063fd6cc58d9e01fd4" - }, - "tests/admin-statuses.spec.ts": { - "reason": "Status rows render dynamic per-id test ids reachable only via prefix matching, and modals lack stable container test ids.", - "violations": 5, - "fingerprint": "86d01d8582373a5b41dcc8e08215621e65d3a40e4cf4761c0615d5978237af6b" - }, - "tests/admin-theme-manager.spec.ts": { - "reason": "Theme modals expose no stable container test id; role=dialog scoping is the exact mount point.", - "violations": 1, - "fingerprint": "9c17d71ba0af77e424b26fa844190e86829d2160f51c6b9385a3dfd48ce2fae2" - }, - "tests/create-modal-workspace-permissions.spec.ts": { - "reason": "Picker options expose no per-option test id; filtering by the workspace key text is the only stable probe. Replace when the workspace picker grows stable option selectors.", - "violations": 2, - "fingerprint": "d153f4cafabb37942c87f3cdaed03ec46abe433888dafc4bc76123f36e6054cc" - }, - "tests/portal-kb-ticket-links.spec.ts": { - "reason": "Portal comment and article anchors render without stable test ids; href-based selectors are the exact target contract. Replace when portal links grow stable selectors.", - "violations": 3, - "fingerprint": "06e47f453606ca81b495b6ec4ffee818d1eb110196f9b07abfa4b5ba6a9b332b" - }, - "tests/portal-knowledge-pages.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes. Reviewed 2026-09-21: the added violations are rich-text content-shape assertions on the ProseMirror DOM and rendered KB article elements, which have no production test IDs.", - "violations": 9, - "fingerprint": "691f0dd40f94b6e961fc1d11e9ad747fb4969e62e1ead5d9b708c19476cd7881" - }, - "tests/portal-passkey.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 3, - "fingerprint": "23ea95558ff1a08dadcee28f9115b28d33fd8c7224540fa7a4c74c26c1760fe5" - }, - "tests/priority-manager.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "aee23366065553519345818e062b5bfa092323766d44bbea9122468bab3e7c38" - }, - "tests/refactor-shots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "544e8d2a840ddfb99350688b7c29a19aadc2f05029efaf6763de3cd7a7ce92d2" - }, - "tests/scroll-reachability.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "4ae2f154d88a7c5342fdf403d4c7563868e05a9bc1dd8dfb4eb33e285896d78a" - }, - "tests/security-page-shortcuts.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 9, - "fingerprint": "458acd68755fc498845a40f518447e3aa9ecf2ddc088a7a2f29e405e714e07f7" - }, - "tests/teams-oncall.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "1908ee387ef89e645453f4851cc70244f7441e1e816f716235bebfed12a21d47" - }, - "tests/teams.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "61dcf89edf4c4819f7024aff366b250ec72ba3bb6fc7260eaedd2cb6060d23eb" - }, - "tests/test-cases-sidebar.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 6, - "fingerprint": "eeeb24c1c18e23986a52a44dad749d63ef9e50993426e222f34a8008e9a7d7e5" - }, - "tests/time-worklog-search.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "4364bd1a6e41fba214d8a06b407ca24c513384660ba7e734017ac825d3a7e5e0" - }, - "tests/users.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 7, - "fingerprint": "78925f0927041b93455b3e0ba41bda86b8a255fb96f78f12aa3fc3bd9b9bb3a3" - }, - "tests/wi-37-agent-comment-owner-tooltip.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 3, - "fingerprint": "1813e6a2e9cfe1a3121cb42c3b4abce3ac89c7008584bb679ec62b9c2ad39f9c" - }, - "tests/wi-6-collection-filter-custom-fields.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "967a6028024604af2b33bd93979099f43cae63f24055b038782b7c312e7adc65" - }, - "tests/workspace-admin-nav.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "ec7a2f813bb93c1b731a750d3ba97f2248ab05f7de739f3d0216d4a0f4adaed3" - }, - "tests/workspace-analytics.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 6, - "fingerprint": "b061f2d90e0c134368d9cb5c98e912741efbfc1edd97f7d1387357bdd921b72b" - }, - "tests/workspace-settings.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "be7fd3d4612e492ea5d481e7ecc49ae59e343b29152d670a340fbba12d25fc25" - }, - "tests/workspaces.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 9, - "fingerprint": "d314f1a945b9ae09d14c9bd322cf346efed7527a68c89c957b526594f2e4a7cb" - }, - "tests/admin-docs-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - }, - "tests/asset-action-workspace-permissions.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "a1795e3727fe1472275fc19d94ea3c16a7a46aaa83b8bf03b219474f2b546a55" - }, - "tests/canned-responses-journey.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "7bb7e87ee618e953457f23527ae32caeb1a730e1010395e3bf9d5672532f426e" - }, - "tests/item-merge-lifecycle.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "6c745cbb852a5bbb560dfc346d14f73d89c0e576514ad74d99baffc1e39b8f00" - }, - "tests/navigation-config.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "14636e8b5e0fd3e1a3251160e119fe4788c5d33896073b7e344880d8b0fe33cd" - }, - "tests/platform-docs-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - }, - "tests/playbook-jira-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 5, - "fingerprint": "c57cef74518dbd728274d61f67e754dad731922e4b758e4c6e8110083553c7e0" - }, - "tests/playbook-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 6, - "fingerprint": "97a5529abb3c1beb58fb0bc081b497313dd75f3e24d630d1c1ed527b91eba83f" - }, - "tests/playbook-service-desk-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 10, - "fingerprint": "b1acae1baf8c4c304f892f5489b8dab6c8b3c382510f82995bc583862854f8f6" - }, - "tests/playbook-time-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - }, - "tests/portal-org-request-sharing.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 3, - "fingerprint": "09e516a877150f78757859f3cfda637a11581d16503e345741ea3a69b5964dbf" - }, - "tests/portal-request-attachments.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 3, - "fingerprint": "e32290880df3f8378136e02fddd2308c905439b67c508bc1c99ac905038b0296" - }, - "tests/sla-clock-breach.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "20737e1b6ec97372a3b93ac8980e80e9e542f9f686c561642898deaa3eb79bfc" - }, - "tests/support-metrics.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 2, - "fingerprint": "c7d4fd9550b69f98643d42936fedd56cecda34b7ab7d0da493ab86a6280c8fc1" - }, - "tests/support-queue.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "0c8dedd9501560ab9f253a0538dbe1487e2bc8e3d248d18c6696da7157f7ea2c" - }, - "tests/test-case-links.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "304ef255b47924316eeaeafe23195462eb8fec8c4f84d6830150d081bd552e57" - }, - "tests/using-windshift-docs-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - }, - "tests/work-management-docs-screenshots.spec.ts": { - "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", - "violations": 1, - "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" - } - } + "schema_version": 1, + "exceptions": { + "fixtures/action-editor-helpers.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "1643d2e6b756cdf0098dd771987ff5c58852738b940c243984cb37b3f0b8825e" + }, + "fixtures/auth.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 4, + "fingerprint": "703f1e51112a0b326f7a17eb9b49775982b85bea8707494343cf0a8e83093595" + }, + "pages/admin.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "7d87a8a2899e30cbb991dea8d86e921a4de83b3d33b491638008ac2ad8426a88" + }, + "pages/board.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 11, + "fingerprint": "b12efe85d5a6bb716d4e0db7b741b9bf8699667836d273c8b04b48f23498436d" + }, + "pages/calendar.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "8a9b02f5a11f0432738acbb1b69ee156511fc2eee9c4729b5135e2928e6991dc" + }, + "pages/custom-fields.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 15, + "fingerprint": "4e18cd0d4feaa01a519620d532319cb108c1f6a8123df0b222061aea970822d0" + }, + "pages/group.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 21, + "fingerprint": "c6caf0840047da16c809d979c50bf38d5577e36cb727ee717cbabd6ef88dc856" + }, + "pages/item-links.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 7, + "fingerprint": "46f431777d54f7a268654e98688df287929023314c06d2a9c6aa76f4950c7a1c" + }, + "pages/item.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 27, + "fingerprint": "c05fe714d8f1cc92def796ffdb3f8bb2703826c936a29c847b250544bebd7511" + }, + "pages/iteration.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 18, + "fingerprint": "fa7ad943ee3ffb004d9ecd81df6d8c3b120790162c9d973bb8d9920d2f7f1d4a" + }, + "pages/knowledge.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "f5b50299f61a8b80bdc6f4d3a103ac9ecc44685f86ca491193cb13949151e51f" + }, + "pages/list-view.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 7, + "fingerprint": "6c810e0f3d212d93825dae95b11cebb41b306004068542234af4363fa10da36f" + }, + "pages/login.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 11, + "fingerprint": "00eb84d674163f4735716fcf97c9a29f3f78fe74c23ce3c6bb46a2daba887275" + }, + "pages/milestone.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 15, + "fingerprint": "548144a8d0ae65996aeafd31ed6475def250a79dd44b1110b86721e8204514cc" + }, + "pages/priority-manager.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 8, + "fingerprint": "b8b1c9bbef0110a893ea98998abdc2200caf0f3c86ebd2b45a8b12c088f262ba" + }, + "pages/team-oncall.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 8, + "fingerprint": "7cdc54cb7a16e1e2bf4f245181ac29d3f01ae1ad87db7f637a68cf9f59967594" + }, + "pages/teams.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 19, + "fingerprint": "1239d9843e806e3158ce2adedba8201bf1072a0cb55ed9480071c8f646ac7509" + }, + "pages/time-tracking.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 25, + "fingerprint": "29931fd8e547df525f2ed0303d91bb60483b1123153e1c12706fadac424b5024" + }, + "pages/user.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 34, + "fingerprint": "ac6fd5df5464b9cd74d039f2bf5dd26bfa2627d4db10891da142c99f49a484b8" + }, + "pages/workspace-settings.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 16, + "fingerprint": "d2f67918fce77540b761f6baaff6a6bb7eca531a128e563394007249da7c2104" + }, + "pages/workspace.page.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 17, + "fingerprint": "8e70195d8650f822559d865af386e007a53d194bd438040798407346e41900a1" + }, + "tests/action-templates-editor-shape.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "81a691706e2efec37dfc2ac6ece148bef7d9e5410a7e77e08d3536b0f9839287" + }, + "tests/action-trigger-node-config.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "a1bed2c8c7e301b57e90929ecbb1777bb1f5e76149ec0796fccf1cf633c963ab" + }, + "tests/admin-docs-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/admin-integrations-providers.spec.ts": { + "reason": "Provider modals expose no stable container test id; role=dialog scoping is the exact mount point. Replace when dialogs grow stable test ids.", + "violations": 2, + "fingerprint": "f61a90914f27f37ce10fba58d32e5701189922457b8bc181736b36a16f08f046" + }, + "tests/admin-llm-connections.spec.ts": { + "reason": "Model picker options carry only data-option-id attributes inside the shared picker list; no per-option test id exists yet.", + "violations": 3, + "fingerprint": "7eee23afdd78bee753b1983aa0e8281c96996429502f22610c278c03132a6047" + }, + "tests/admin-oauth-clients.spec.ts": { + "reason": "Scope inputs and modals lack stable test ids; role=dialog and nested input scoping is the current contract.", + "violations": 2, + "fingerprint": "835841f811c5b7e881942b31ac17351917cb338c9b4e58063fd6cc58d9e01fd4" + }, + "tests/admin-settings.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "d4800e15216f7bbf3ccb72fbd35927d2bc8b0241ed3cf7147eaefa807b439066" + }, + "tests/admin-statuses.spec.ts": { + "reason": "Status rows render dynamic per-id test ids reachable only via prefix matching, and modals lack stable container test ids.", + "violations": 5, + "fingerprint": "86d01d8582373a5b41dcc8e08215621e65d3a40e4cf4761c0615d5978237af6b" + }, + "tests/admin-theme-manager.spec.ts": { + "reason": "Theme modals expose no stable container test id; role=dialog scoping is the exact mount point.", + "violations": 1, + "fingerprint": "9c17d71ba0af77e424b26fa844190e86829d2160f51c6b9385a3dfd48ce2fae2" + }, + "tests/agent-token-revoke.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 4, + "fingerprint": "3d7af97c7414a1d68fff0b0e125d51bad5ef6d1e31dacc7b682886f4c01b1e96" + }, + "tests/ai-chat-availability.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "1888240c4f461e777c89595f765c6a7946626502ddd68368afcdf00a026bbe85" + }, + "tests/asset-action-workspace-permissions.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "a1795e3727fe1472275fc19d94ea3c16a7a46aaa83b8bf03b219474f2b546a55" + }, + "tests/authentication.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "f604a63aee8cd3bec98de84ada4ddf49d67ae8b26e8bf06fb92bd080ef7ba0a9" + }, + "tests/blog-portal-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "210ae06a2ea3637fcc760e030e69c0768188a787d427f63473cc5178bd72adf1" + }, + "tests/board-bubble-sort.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 8, + "fingerprint": "21ce2f1d5bcabd69198fe5b47c9e37add48d8fc483fbdfec2488b04c01fafd90" + }, + "tests/board-swimlane-quick-add-types.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "ff5569e3b73fe27ca807cff1b468e57ead9dddbfbd32d38f3d3cfe7f433efc35" + }, + "tests/board-view.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "15f859a562711ebade35f346e8e8bb954b2bfc3ca0a0883e52809141c5cabfd7" + }, + "tests/button-smoke.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 9, + "fingerprint": "eeaee9edc3bf39f4ddefd166a51fe138cc982ba55d5f93dfa57993b5c5250dfd" + }, + "tests/canned-responses-journey.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "7bb7e87ee618e953457f23527ae32caeb1a730e1010395e3bf9d5672532f426e" + }, + "tests/chat-action-context.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 4, + "fingerprint": "2b69acbb7b2101b6c93eea31b261cc65ab5ddd130aa348be92af375e1ad66a6c" + }, + "tests/collaboration.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "ce0be3c5cf5fc951f7582725b5b57d1e0ebbcdaff165ed90aad50adbfee34d67" + }, + "tests/collection-filter-menu-active.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "8b7264b0b36805c7ea1337247e429c3b6dcbbcdfee43c68a1ad71ed55490e797" + }, + "tests/create-modal-asset-custom-field.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "7f275e4027dbcbb402f57a0d50700ee69ec8c9943bcff10a49842cfb8f054d18" + }, + "tests/create-modal-workspace-permissions.spec.ts": { + "reason": "Picker options expose no per-option test id; filtering by the workspace key text is the only stable probe. Replace when the workspace picker grows stable option selectors.", + "violations": 2, + "fingerprint": "d153f4cafabb37942c87f3cdaed03ec46abe433888dafc4bc76123f36e6054cc" + }, + "tests/create-modal-workspace-switch.spec.ts": { + "reason": "The workspace chip renders one shared option test ID with no per-workspace ID, so the option is disambiguated by its workspace key text.", + "violations": 1, + "fingerprint": "b9e3e25904b9032d5ae0434f52a42a7b0ef03af2e66fff55937d4639d39945eb" + }, + "tests/critical-browser-page.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "d36a73f373b9ba6ffd7184888885c0d30db771caf3e1de28893d3d867d7c639e" + }, + "tests/docs-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/editor-scroll-audit.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "b41cedbb3b3b97d91cfdb206da8128f1b7f7dea94d970d8b9a26c5a7e95eedfc" + }, + "tests/email-templates.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 9, + "fingerprint": "77665892ab055d87e1b0f29e5fb4be994ccf84f3d74ee9ab71a3056e7489cddf" + }, + "tests/fresh-setup-restart.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "e41ac6435a3d78c20e5985fc8093de750aea0120a07ffa3732e83175b258a5b6" + }, + "tests/global-permissions.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 49, + "fingerprint": "222ea2ad882fb0e5aa447a56fb9358e1cc6ce8d1e66ca9aa7b91121f3b702fc9" + }, + "tests/global-search.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "0c542a24110f22ccde322d4625c5bcf5b2561feab2a74c4c739090f87cc29454" + }, + "tests/groups.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "07b1e2eaa6ec7e06db478c4ae738c33bc06cc14cabdccd5098d519cbb5c9a64c" + }, + "tests/item-detail-sse-live.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "ad7fb4f153213d784154c5e87bc363ead891d61a16de37275f4c4833ea0f21a6" + }, + "tests/item-links.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "746938c879071a71f9f894a4f8f38bafe2266572304f2f32446b74ac70dcd9e0" + }, + "tests/item-merge-lifecycle.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "6c745cbb852a5bbb560dfc346d14f73d89c0e576514ad74d99baffc1e39b8f00" + }, + "tests/item-modal-comment-escape.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "8d05e4fe7c679f7ee26fdabbf0b4f670d336fe13f83edd66f3b632e65beaa20e" + }, + "tests/item-templates.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 8, + "fingerprint": "d4298daa763813c1bd777885949274b600d66142523bcd3def17b77f35fec587" + }, + "tests/items.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 12, + "fingerprint": "6b756f08f876710489e7827cb1530ec5be07e81b7331d547e7020f76957aa54e" + }, + "tests/iteration-reference-invalidation.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "6e4f50fca2fed09d0ba9814b1285ae0778f56e69cf49c5645b4a751258654521" + }, + "tests/iterations.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 8, + "fingerprint": "74e18143577f4f3fae7239849f9526b82bb87c96359541ac8297d675f2dba2c9" + }, + "tests/knowledge-pages.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "54d8d74e0b71b5fdf8f6df4d0d70ae88e8869fa12c0d49b941a43093b034dd24" + }, + "tests/milestones.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 10, + "fingerprint": "c388b7e38b6359bf30fed622160376db9016ec3d210c1a33cb19e7df78617959" + }, + "tests/mobile-surface.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 11, + "fingerprint": "8ad501d940d34cd2489eab309edf0f66f22e582dc6f856ca7a2d75b39718b12b" + }, + "tests/navigation-config.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "18ece338571c402fc1abed636bcb8c44d39a750d1b29396b7f3f0f72451ed035" + }, + "tests/notifications.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "099c9f024a0b7ba23943d2870e98f2bb1e534d68f66bc4353f2e68ce3ea376df" + }, + "tests/page-labels.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "558523c2f8bb5d19434b6c053fb5e6defafb3a82693b9f77bb984c86629059af" + }, + "tests/page-links.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 9, + "fingerprint": "b5622caf33271c6a1dab3251e5b76519f2474ea5459a302429f8b5e2f3c248c5" + }, + "tests/page-title-layout.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "d5c939510d6a5b328c5c291455d415a5ac3d9f5c2f74dc917f0d4dc577c20fe6" + }, + "tests/pages-hardbreak-br.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "5713090de8894864cad6fa1cf3b175228d8da071957470f7d00f01336b8d399d" + }, + "tests/pages-history.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "d319b2431abd1917336b5f9c6555abebb07d7d7233ff69fc7fe54c5758bb259b" + }, + "tests/pages-print.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 4, + "fingerprint": "335957669e47c800ffe5097db7872c9edd1d3e4731d01e0206051d1d30cff920" + }, + "tests/platform-docs-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/playbook-jira-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "c57cef74518dbd728274d61f67e754dad731922e4b758e4c6e8110083553c7e0" + }, + "tests/playbook-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 6, + "fingerprint": "97a5529abb3c1beb58fb0bc081b497313dd75f3e24d630d1c1ed527b91eba83f" + }, + "tests/playbook-service-desk-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 10, + "fingerprint": "b1acae1baf8c4c304f892f5489b8dab6c8b3c382510f82995bc583862854f8f6" + }, + "tests/playbook-time-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/portal-channel-toggle.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 6, + "fingerprint": "ccbe4caed843b8b748dd03e2fcdc9956e3ced035c9f5ea5cefa4282de2035471" + }, + "tests/portal-draft-resume.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "017581fe241ee670941c621aa2009a37a2cd76f7b04159fda5fe8bc03f3b8b57" + }, + "tests/portal-draft-schema-change.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "017581fe241ee670941c621aa2009a37a2cd76f7b04159fda5fe8bc03f3b8b57" + }, + "tests/portal-kb-ticket-links.spec.ts": { + "reason": "Portal comment and article anchors render without stable test ids; href-based selectors are the exact target contract. Replace when portal links grow stable selectors.", + "violations": 3, + "fingerprint": "06e47f453606ca81b495b6ec4ffee818d1eb110196f9b07abfa4b5ba6a9b332b" + }, + "tests/portal-knowledge-pages.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes. Reviewed 2026-09-21: the added violations are rich-text content-shape assertions on the ProseMirror DOM and rendered KB article elements, which have no production test IDs.", + "violations": 9, + "fingerprint": "691f0dd40f94b6e961fc1d11e9ad747fb4969e62e1ead5d9b708c19476cd7881" + }, + "tests/portal-org-request-sharing.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "09e516a877150f78757859f3cfda637a11581d16503e345741ea3a69b5964dbf" + }, + "tests/portal-passkey.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "23ea95558ff1a08dadcee28f9115b28d33fd8c7224540fa7a4c74c26c1760fe5" + }, + "tests/portal-request-attachments.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "e32290880df3f8378136e02fddd2308c905439b67c508bc1c99ac905038b0296" + }, + "tests/priority-manager.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "aee23366065553519345818e062b5bfa092323766d44bbea9122468bab3e7c38" + }, + "tests/refactor-shots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "544e8d2a840ddfb99350688b7c29a19aadc2f05029efaf6763de3cd7a7ce92d2" + }, + "tests/reference-data-invalidation.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 4, + "fingerprint": "89bc073cd8c92a07a498eccc2c9c418872a00857ab1265e2df4e61e9335436cb" + }, + "tests/scroll-reachability.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "4ae2f154d88a7c5342fdf403d4c7563868e05a9bc1dd8dfb4eb33e285896d78a" + }, + "tests/security-page-shortcuts.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 9, + "fingerprint": "458acd68755fc498845a40f518447e3aa9ecf2ddc088a7a2f29e405e714e07f7" + }, + "tests/sla-clock-breach.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "20737e1b6ec97372a3b93ac8980e80e9e542f9f686c561642898deaa3eb79bfc" + }, + "tests/support-metrics.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "c7d4fd9550b69f98643d42936fedd56cecda34b7ab7d0da493ab86a6280c8fc1" + }, + "tests/support-queue.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "0c8dedd9501560ab9f253a0538dbe1487e2bc8e3d248d18c6696da7157f7ea2c" + }, + "tests/teams-oncall.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "1908ee387ef89e645453f4851cc70244f7441e1e816f716235bebfed12a21d47" + }, + "tests/teams.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "61dcf89edf4c4819f7024aff366b250ec72ba3bb6fc7260eaedd2cb6060d23eb" + }, + "tests/test-case-links.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "304ef255b47924316eeaeafe23195462eb8fec8c4f84d6830150d081bd552e57" + }, + "tests/test-cases-sidebar.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 6, + "fingerprint": "eeeb24c1c18e23986a52a44dad749d63ef9e50993426e222f34a8008e9a7d7e5" + }, + "tests/time-worklog-search.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 2, + "fingerprint": "4364bd1a6e41fba214d8a06b407ca24c513384660ba7e734017ac825d3a7e5e0" + }, + "tests/users.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 7, + "fingerprint": "78925f0927041b93455b3e0ba41bda86b8a255fb96f78f12aa3fc3bd9b9bb3a3" + }, + "tests/using-windshift-docs-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/wi-37-agent-comment-owner-tooltip.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 3, + "fingerprint": "1813e6a2e9cfe1a3121cb42c3b4abce3ac89c7008584bb679ec62b9c2ad39f9c" + }, + "tests/wi-6-collection-filter-custom-fields.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "967a6028024604af2b33bd93979099f43cae63f24055b038782b7c312e7adc65" + }, + "tests/work-management-docs-screenshots.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "56b18ba457f17061cacf26e0da1bfeac457f21bbd770326b4a1be3b4db7044c1" + }, + "tests/workspace-admin-nav.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 1, + "fingerprint": "ec7a2f813bb93c1b731a750d3ba97f2248ab05f7de739f3d0216d4a0f4adaed3" + }, + "tests/workspace-analytics.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 6, + "fingerprint": "b061f2d90e0c134368d9cb5c98e912741efbfc1edd97f7d1387357bdd921b72b" + }, + "tests/workspace-settings.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 5, + "fingerprint": "be7fd3d4612e492ea5d481e7ecc49ae59e343b29152d670a340fbba12d25fc25" + }, + "tests/workspaces.spec.ts": { + "reason": "Legacy selector debt; replace with production test IDs when this surface changes.", + "violations": 9, + "fingerprint": "d314f1a945b9ae09d14c9bd322cf346efed7527a68c89c957b526594f2e4a7cb" + } + } } diff --git a/e2e/tests/action-node-config-ai-inputs.spec.ts b/e2e/tests/action-node-config-ai-inputs.spec.ts index 1d68499..eda6015 100644 --- a/e2e/tests/action-node-config-ai-inputs.spec.ts +++ b/e2e/tests/action-node-config-ai-inputs.spec.ts @@ -41,9 +41,7 @@ test.describe('Action editor — ai_agent input fields', () => { test('adding input fields as chips persists an array', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `ai-inputs-${stamp}`, @@ -74,9 +72,7 @@ test.describe('Action editor — ai_agent input fields', () => { test('stored input_fields hydrate back to chips', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `ai-inputs-hyd-${stamp}`, diff --git a/e2e/tests/action-node-config-helpdesk.spec.ts b/e2e/tests/action-node-config-helpdesk.spec.ts index 663e053..5694963 100644 --- a/e2e/tests/action-node-config-helpdesk.spec.ts +++ b/e2e/tests/action-node-config-helpdesk.spec.ts @@ -67,9 +67,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => test('palette exposes notify_customer, insert_canned_response, and adjust_labels', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-palette-${stamp}`, @@ -95,9 +93,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => test('comment_created trigger persists the from_customer filter', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-comment-${stamp}`, @@ -122,8 +118,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => expect(JSON.parse(fresh.trigger_config).from_customer).toBe(true); }); - test('item_inactive trigger persists inactive_hours', async ({ page, request, allowConsoleError }) => { - allowConsoleError(/\/api\/logbook\//); + test('item_inactive trigger persists inactive_hours', async ({ page, request }) => { const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-inactive-${stamp}`, @@ -148,8 +143,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => expect(JSON.parse(fresh.trigger_config).inactive_hours).toBe(48); }); - test('notify_customer subject and message persist', async ({ page, request, allowConsoleError }) => { - allowConsoleError(/\/api\/logbook\//); + test('notify_customer subject and message persist', async ({ page, request }) => { const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-notify-${stamp}`, @@ -183,9 +177,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => test('insert_canned_response picker persists the selected response id', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-canned-${stamp}`, @@ -217,8 +209,7 @@ test.describe('Action editor — helpdesk automation nodes and triggers', () => expect(nodeConfigByType(fresh, 'insert_canned_response').canned_response_id).toBe(canned.id); }); - test('adjust_labels persists add and remove selections', async ({ page, request, allowConsoleError }) => { - allowConsoleError(/\/api\/logbook\//); + test('adjust_labels persists add and remove selections', async ({ page, request }) => { const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `helpdesk-labels-${stamp}`, diff --git a/e2e/tests/action-node-config-http-headers.spec.ts b/e2e/tests/action-node-config-http-headers.spec.ts index 6d36ea8..4eacab2 100644 --- a/e2e/tests/action-node-config-http-headers.spec.ts +++ b/e2e/tests/action-node-config-http-headers.spec.ts @@ -38,9 +38,7 @@ test.describe('Action editor — http_request headers', () => { test('adding header rows persists and hydrates the headers map', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `http-headers-${stamp}`, diff --git a/e2e/tests/action-node-config-notify-recipients.spec.ts b/e2e/tests/action-node-config-notify-recipients.spec.ts index db828d3..2556c8b 100644 --- a/e2e/tests/action-node-config-notify-recipients.spec.ts +++ b/e2e/tests/action-node-config-notify-recipients.spec.ts @@ -39,9 +39,7 @@ test.describe('Action editor — notify_user specific recipients', () => { test('picking two users by name persists recipients as id strings', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `notify-recip-${stamp}`, @@ -90,9 +88,7 @@ test.describe('Action editor — notify_user specific recipients', () => { test('stored recipient ids hydrate back to user names', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `notify-hydrate-${stamp}`, @@ -121,9 +117,7 @@ test.describe('Action editor — notify_user specific recipients', () => { test('legacy numeric recipients with no recipient_type hydrate to specific', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `notify-legacy-${stamp}`, diff --git a/e2e/tests/action-node-config-output-validation.spec.ts b/e2e/tests/action-node-config-output-validation.spec.ts index 6d57bf4..7418015 100644 --- a/e2e/tests/action-node-config-output-validation.spec.ts +++ b/e2e/tests/action-node-config-output-validation.spec.ts @@ -15,9 +15,7 @@ test.describe('Action editor — output field validation', () => { test('invalid output name shows inline error; valid name saves', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `output-valid-${stamp}`, @@ -67,9 +65,7 @@ test.describe('Action editor — output field validation', () => { test('whitespace-padded valid name serializes trimmed', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `output-trim-${stamp}`, diff --git a/e2e/tests/action-node-config-related-items.spec.ts b/e2e/tests/action-node-config-related-items.spec.ts index bd67aed..4671515 100644 --- a/e2e/tests/action-node-config-related-items.spec.ts +++ b/e2e/tests/action-node-config-related-items.spec.ts @@ -16,9 +16,7 @@ test.describe('Action editor — related_items linked config', () => { test('configuring a linked relation persists link type, direction and max items', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `related-linked-${stamp}`, diff --git a/e2e/tests/action-node-config-transition-category.spec.ts b/e2e/tests/action-node-config-transition-category.spec.ts index 5479f7f..df519d1 100644 --- a/e2e/tests/action-node-config-transition-category.spec.ts +++ b/e2e/tests/action-node-config-transition-category.spec.ts @@ -27,9 +27,7 @@ test.describe('Action editor — transition_item category picker', () => { test('choosing a status category by name persists target.category_name', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `transition-cat-${stamp}`, diff --git a/e2e/tests/action-node-config-trigger-filters.spec.ts b/e2e/tests/action-node-config-trigger-filters.spec.ts index 6b2f65d..b33493e 100644 --- a/e2e/tests/action-node-config-trigger-filters.spec.ts +++ b/e2e/tests/action-node-config-trigger-filters.spec.ts @@ -51,9 +51,7 @@ test.describe('Action editor — trigger filters', () => { test('item_created exposes an item-type control that serializes item_type_id', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `trig-itemtype-${stamp}`, @@ -83,9 +81,7 @@ test.describe('Action editor — trigger filters', () => { test('item_linked exposes a link-type control that serializes link_type_id', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `trig-linktype-${stamp}`, @@ -109,9 +105,7 @@ test.describe('Action editor — trigger filters', () => { test('status_transition completed toggle serializes to_status_category_completed and clears to_status_id', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `trig-completed-${stamp}`, @@ -140,4 +134,34 @@ test.describe('Action editor — trigger filters', () => { expect(cfg.to_status_category_completed).toBe(true); expect(cfg.to_status_id ?? null).toBeNull(); }); + + test('status_loop exposes a looped-status control that serializes to_status_id', async ({ + page, + request, + }) => { + const stamp = Date.now(); + const ws = await createWorkspaceViaAPI(request, { + name: `trig-loop-${stamp}`, + key: `TLP${stamp.toString().slice(-7)}`.toUpperCase(), + description: 'trigger status loop e2e', + }); + const statuses = await workspaceStatuses(request, ws.id); + const chosen = statuses[0]; + + const action = await createActionViaAPI(request, ws.id, triggerOnlyAction(stamp, 'status_loop')); + + await openActionEditor(page, ws.id, action.id); + await selectNodeByType(page, 'trigger'); + await expect(page.locator('#config-trigger-type')).toContainText('Status Loop'); + await chooseSelectOption(page, 'config-loop-status', chosen.id); + await saveAction(page, ws.id, action.id); + + const fresh = await getActionViaAPI(request, ws.id, action.id); + expect(JSON.parse(fresh.trigger_config).to_status_id).toBe(chosen.id); + + // Hydration: reopening shows the looped status name, not the raw id. + await openActionEditor(page, ws.id, fresh.id); + await selectNodeByType(page, 'trigger'); + await expect(page.locator('#config-loop-status')).toContainText(chosen.name); + }); }); diff --git a/e2e/tests/action-templates-editor-shape.spec.ts b/e2e/tests/action-templates-editor-shape.spec.ts index a2b5ede..1ee8494 100644 --- a/e2e/tests/action-templates-editor-shape.spec.ts +++ b/e2e/tests/action-templates-editor-shape.spec.ts @@ -53,12 +53,7 @@ test.describe('Action templates: editor shape after apply', () => { test(`template "${tc.templateKey}" renders correct editor shape`, async ({ page, request, - allowConsoleError, }) => { - // Optional logbook service emits a 404 on /api/logbook/health when not - // deployed; established noise pattern, see button-smoke.spec.ts. - allowConsoleError(/\/api\/logbook\//); - const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { name: `tpl-shape-${tc.templateKey}-${stamp}`, diff --git a/e2e/tests/action-trigger-node-config.spec.ts b/e2e/tests/action-trigger-node-config.spec.ts index 3013fc8..288d1f2 100644 --- a/e2e/tests/action-trigger-node-config.spec.ts +++ b/e2e/tests/action-trigger-node-config.spec.ts @@ -8,9 +8,7 @@ test.describe('Action editor trigger/node config contracts', () => { test('saving an applied template in the UI preserves action trigger_config', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { diff --git a/e2e/tests/admin-llm-connections.spec.ts b/e2e/tests/admin-llm-connections.spec.ts index 66e331e..1997df6 100644 --- a/e2e/tests/admin-llm-connections.spec.ts +++ b/e2e/tests/admin-llm-connections.spec.ts @@ -59,7 +59,7 @@ test.describe("Admin LLM connections", () => { await page.locator("#llm-connection-model").click(); await page .getByTestId("picker-option-list") - .locator('[data-option-id="claude-haiku-4-5-20251001"]') + .locator('[data-option-id="claude-sonnet-5"]') .click(); // Synchronize on the create response before listing; the admin list is // not guaranteed to have refreshed when the click resolves. @@ -75,7 +75,7 @@ test.describe("Admin LLM connections", () => { expect(created, "created connection persisted").toBeDefined(); createdId = created!.id; expect(created!.provider_type).toBe("anthropic"); - expect(created!.model).toBe("claude-haiku-4-5-20251001"); + expect(created!.model).toBe("claude-sonnet-5"); const row = page.getByTestId(`llm-connection-row-${createdId}`); await expect(row).toBeVisible(); diff --git a/e2e/tests/admin-theme-branding.spec.ts b/e2e/tests/admin-theme-branding.spec.ts new file mode 100644 index 0000000..cbf1c2f --- /dev/null +++ b/e2e/tests/admin-theme-branding.spec.ts @@ -0,0 +1,123 @@ +import { restoreActiveTheme } from "../fixtures/api-helpers"; +import { expect, type APIRequestContext, test } from "../fixtures/context-path"; +import { acquireGlobalLock } from "../fixtures/global-lock"; + +/** + * Verifies the theme brand lockup (full logo vs mark + site title) and the + * instance site title surfaced by the General settings page. Both the active + * theme and the site title are global singletons, so the test holds the shared + * active-theme lock while it mutates them and restores the originals after. + */ + +const SEC_FETCH = { "Sec-Fetch-Site": "same-origin" }; + +interface Theme { + id: number; + name: string; + is_active: boolean; +} + +interface Branding { + site_title: string; +} + +async function listThemes(request: APIRequestContext): Promise { + const response = await request.get("/api/themes", { headers: SEC_FETCH }); + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + +test.describe("Theme brand lockup and site title", () => { + test("full logo replaces the title and mark_text shows the site title", async ({ + page, + request, + }) => { + const release = await acquireGlobalLock("active-theme"); + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const name = `E2E Brand Theme ${suffix}`; + const siteTitle = `E2E Site ${suffix}`; + let originalActive: Theme | undefined; + let originalSiteTitle = ""; + let created: Theme | undefined; + try { + const brandingResponse = await request.get("/api/branding", { headers: SEC_FETCH }); + expect(brandingResponse.ok()).toBeTruthy(); + originalSiteTitle = ((await brandingResponse.json()) as Branding).site_title; + + originalActive = (await listThemes(request)).find((theme) => theme.is_active); + expect(originalActive, "an active theme exists").toBeDefined(); + + const baseTheme = { + description: "Brand lockup fixture", + nav_background_color_light: "#ffffff", + nav_text_color_light: "#374151", + nav_background_color_dark: "#1f2937", + nav_text_color_dark: "#f3f4f6", + logo_url: "/api/portal-assets/mark-fixture", + logo_full_url: "/api/portal-assets/full-fixture", + }; + + const createResponse = await request.post("/api/themes", { + headers: SEC_FETCH, + data: { ...baseTheme, name, brand_layout: "full_logo" }, + }); + expect(createResponse.ok()).toBeTruthy(); + created = (await createResponse.json()) as Theme; + + const activate = await request.post(`/api/themes/${created.id}/activate`, { + headers: SEC_FETCH, + }); + expect(activate.ok()).toBeTruthy(); + + const setTitle = await request.put("/api/admin/general-settings", { + headers: SEC_FETCH, + data: { site_title: siteTitle }, + }); + expect(setTitle.ok()).toBeTruthy(); + + // Keep the sidebar expanded so the brand title renders. + await page.addInitScript(() => { + try { + window.localStorage.setItem("windshift-nav-expanded", "true"); + } catch { + // Storage is unavailable on non-origin documents. + } + }); + + await page.goto("/"); + const navLogo = page.getByTestId("nav-logo"); + await expect(navLogo).toHaveAttribute("src", "/api/portal-assets/full-fixture"); + await expect(page.getByTestId("nav-brand-title")).toHaveCount(0); + + // Switching to mark_text shows the mark plus the instance site title. + const switchLayout = await request.put(`/api/themes/${created.id}`, { + headers: SEC_FETCH, + data: { ...baseTheme, name, brand_layout: "mark_text", is_active: true }, + }); + expect(switchLayout.ok()).toBeTruthy(); + + await page.reload(); + await expect(navLogo).toHaveAttribute("src", "/api/portal-assets/mark-fixture"); + await expect(page.getByTestId("nav-brand-title")).toHaveText(siteTitle); + + // The General settings page saves the same title and updates the + // sidebar live through the admin-mutation refresh. + await page.goto("/admin/general"); + const updatedTitle = `${siteTitle} Updated`; + await page.getByTestId("general-site-title").fill(updatedTitle); + await page.getByTestId("general-settings-save").click(); + await expect(page.getByTestId("nav-brand-title")).toHaveText(updatedTitle); + await expect(page).toHaveTitle(updatedTitle); + } finally { + if (created) { + await request.delete(`/api/themes/${created.id}`, { headers: SEC_FETCH }); + } + await restoreActiveTheme(request, originalActive); + await request.put("/api/admin/general-settings", { + headers: SEC_FETCH, + data: { site_title: originalSiteTitle }, + }); + await release(); + } + }); +}); diff --git a/e2e/tests/admin-theme-logos.spec.ts b/e2e/tests/admin-theme-logos.spec.ts index ae1a616..90a54ca 100644 --- a/e2e/tests/admin-theme-logos.spec.ts +++ b/e2e/tests/admin-theme-logos.spec.ts @@ -1,12 +1,13 @@ import { restoreActiveTheme } from "../fixtures/api-helpers"; import { expect, type APIRequestContext, test } from "../fixtures/context-path"; +import { acquireGlobalLock } from "../fixtures/global-lock"; /** * Verifies that the active theme's light/dark logos are selected from the * resolved color mode, with a fallback to the light logo when no dark logo is * configured. Themes are created through the admin API so the app builds state - * the same way an operator would. The active theme is global, so the scenarios - * run in one serial test rather than racing across parallel workers. + * the same way an operator would. The active theme is global, so the test holds + * the shared active-theme lock while it mutates it. */ const SEC_FETCH = { "Sec-Fetch-Site": "same-origin" }; @@ -30,13 +31,17 @@ test.describe("Theme logo selection", () => { page, request, }) => { - const stamp = Date.now(); - const name = `E2E Logo Theme ${stamp}`; - const originalActive = (await listThemes(request)).find((theme) => theme.is_active); - expect(originalActive, "an active theme exists").toBeDefined(); - + // The active theme is a global singleton, so serialize against the theme + // manager spec instead of deactivating each other mid-assertion. + const release = await acquireGlobalLock("active-theme"); + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const name = `E2E Logo Theme ${suffix}`; + let originalActive: Theme | undefined; let created: Theme | undefined; try { + originalActive = (await listThemes(request)).find((theme) => theme.is_active); + expect(originalActive, "an active theme exists").toBeDefined(); + const createResponse = await request.post("/api/themes", { headers: SEC_FETCH, data: { @@ -102,6 +107,7 @@ test.describe("Theme logo selection", () => { await request.delete(`/api/themes/${created.id}`, { headers: SEC_FETCH }); } await restoreActiveTheme(request, originalActive); + await release(); } }); }); diff --git a/e2e/tests/admin-theme-manager.spec.ts b/e2e/tests/admin-theme-manager.spec.ts index 839ed22..da05d08 100644 --- a/e2e/tests/admin-theme-manager.spec.ts +++ b/e2e/tests/admin-theme-manager.spec.ts @@ -1,5 +1,6 @@ import { restoreActiveTheme } from "../fixtures/api-helpers"; import { expect, type APIRequestContext, test } from "../fixtures/context-path"; +import { acquireGlobalLock } from "../fixtures/global-lock"; /** * Admin theme manager (/admin/themes). Creates a theme with the default @@ -24,24 +25,36 @@ async function listThemes(request: APIRequestContext): Promise { test.describe("Admin theme manager", () => { test("creates, activates, and deletes a theme", async ({ page, request }) => { - const stamp = Date.now(); - const name = `E2E Theme ${stamp}`; - const originalActive = (await listThemes(request)).find((theme) => theme.is_active); - expect(originalActive, "an active theme exists").toBeDefined(); + // The active theme is a global singleton, so serialize against the other + // theme spec instead of deactivating each other mid-assertion. + const release = await acquireGlobalLock("active-theme"); + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const name = `E2E Theme ${suffix}`; + let originalActive: Theme | undefined; let createdId = 0; try { + originalActive = (await listThemes(request)).find((theme) => theme.is_active); + expect(originalActive, "an active theme exists").toBeDefined(); + await page.goto("/admin/themes"); await page.getByTestId("theme-add").click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible(); await dialog.locator("#name").fill(name); - await page.getByTestId("dialog-confirm").click(); - const created = (await listThemes(request)).find((theme) => theme.name === name); - expect(created, "created theme persisted").toBeDefined(); - createdId = created!.id; + // Wait for the create response so the id is read from the request that + // actually persisted the theme instead of racing a list refetch. + const createResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname.endsWith("/api/themes"), + ); + await page.getByTestId("dialog-confirm").click(); + const response = await createResponse; + expect(response.status(), "created theme status").toBe(201); + createdId = (await response.json()).id; const card = page.getByTestId(`theme-card-${createdId}`); await expect(card).toBeVisible(); @@ -66,10 +79,19 @@ test.describe("Admin theme manager", () => { .toBe(true); createdId = 0; } finally { - if (createdId) { - await request.delete(`/api/themes/${createdId}`, { headers: SEC_FETCH }); + // Fall back to a name lookup so a theme that persisted without a captured + // id is still cleaned up. + const leftover = + createdId || + (await listThemes(request) + .then((themes) => themes.find((theme) => theme.name === name)?.id) + .catch(() => undefined)) || + 0; + if (leftover) { + await request.delete(`/api/themes/${leftover}`, { headers: SEC_FETCH }); } await restoreActiveTheme(request, originalActive); + await release(); } }); }); diff --git a/e2e/tests/asset-import-required-fields-ui.spec.ts b/e2e/tests/asset-import-required-fields-ui.spec.ts index 7fc12b9..df5ac6d 100644 --- a/e2e/tests/asset-import-required-fields-ui.spec.ts +++ b/e2e/tests/asset-import-required-fields-ui.spec.ts @@ -36,7 +36,7 @@ test("CSV import UI rejects a row whose mapped required field is blank", async ( ).toBeTruthy(); const assetType = (await typeResponse.json()).data; - const fieldResponse = await request.post("/api/admin/custom-fields", { + const fieldResponse = await request.post("/api/v2/custom-fields", { headers: SEC_FETCH, data: { name: fieldName, field_type: "text" }, }); @@ -44,7 +44,7 @@ test("CSV import UI rejects a row whose mapped required field is blank", async ( fieldResponse.ok(), `create custom field: ${await fieldResponse.text()}`, ).toBeTruthy(); - const customField = await fieldResponse.json(); + const customField = (await fieldResponse.json()).data.custom_field; const attachResponse = await request.put( `/api/v2/asset-types/${assetType.id}/fields`, diff --git a/e2e/tests/attachment-visibility-toggle.spec.ts b/e2e/tests/attachment-visibility-toggle.spec.ts new file mode 100644 index 0000000..bec718b --- /dev/null +++ b/e2e/tests/attachment-visibility-toggle.spec.ts @@ -0,0 +1,171 @@ +import { authenticateAdminRequest, createItemViaAPI } from '../fixtures/api-helpers'; +import { expect, test } from '../fixtures/mail'; +import { + attachRequestTypesToSection, + createPortalChannel, + createSimpleRequestType, + signInViaMagicLink, +} from '../helpers/portal-setup'; + +/** + * Attachment visibility toggle (WI-2032). + * + * An editor opens a customer request, hides a file from the customer with the + * visibility icon next to delete, and the portal timeline stops offering it. + * The toggle is offered only on customer requests, so a plain agent-created + * item shows no visibility affordance. + * + * Complements tests/attachment_visibility_toggle_test.go which pins the HTTP + * contract; this spec proves the browser-visible path. + */ + +const BASE_ORIGIN = process.env.BASE_ORIGIN || 'http://localhost:8080'; + +// 1x1 transparent PNG. +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +function pngBuffer() { + return Buffer.from(PNG_BASE64, 'base64'); +} + +test.describe('Attachment visibility toggle', () => { + test('editor hides a customer attachment and the portal stops offering it', { + tag: '@critical-browser', + }, async ({ request, mail, page, browser }) => { + mail.skipIfMissing(); + await authenticateAdminRequest(request); + + const stamp = Date.now(); + const slug = `e2e-vis-${stamp}`; + const ownerEmail = `e2e-vis-owner-${stamp}@windshift.test`; + const title = `Visibility journey ${stamp}`; + + const channel = await createPortalChannel(request, { + slug, + name: `Visibility ${stamp}`, + }); + const rt = await createSimpleRequestType(request, channel, { name: 'General request' }); + await attachRequestTypesToSection(request, channel, [rt.id]); + + // Customer submits a request with one attachment. + const customerContext = await browser.newContext({ + baseURL: BASE_ORIGIN, + storageState: { cookies: [], origins: [] }, + }); + const customerPage = await customerContext.newPage(); + await customerPage.goto(`/portal/${slug}`); + await customerPage.locator('#portal-sign-in').click(); + await signInViaMagicLink(customerPage, { mail, slug, email: ownerEmail }); + await expect(customerPage.getByTestId('portal-request-type-card')).toBeVisible({ + timeout: 10_000, + }); + await customerPage.getByTestId('portal-request-type-card').click(); + await expect(customerPage.locator('#request-title')).toBeVisible({ timeout: 5000 }); + await customerPage.locator('#request-title').fill(title); + await customerPage.locator('#request-form-attachment-input').setInputFiles({ + name: 'evidence.png', + mimeType: 'image/png', + buffer: pngBuffer(), + }); + + const submitPromise = customerPage.waitForResponse( + (resp) => + resp.url().includes(`/api/portal/${slug}/submit`) && resp.request().method() === 'POST' + ); + const uploadPromise = customerPage.waitForResponse( + (resp) => resp.url().includes('/attachments') && resp.request().method() === 'POST' + ); + await customerPage.getByTestId('request-form-submit').click(); + const itemId = (await (await submitPromise).json()).item_id; + await uploadPromise; + expect(itemId).toBeGreaterThan(0); + + // The customer sees the staged file in the request timeline. + await expect(customerPage).toHaveURL(new RegExp(`[?&]view=requests(?:&id=${itemId})?`), { + timeout: 10_000, + }); + const customerAttachment = customerPage.getByTestId('portal-request-attachment'); + await expect(customerAttachment).toHaveCount(1, { timeout: 5000 }); + + // Admin resolves the attachment id through the production API. + const attachmentsResp = await request.get(`/api/v2/items/${itemId}/attachments`); + expect(attachmentsResp.ok()).toBeTruthy(); + const attachmentId = (await attachmentsResp.json()).data[0].id; + expect(attachmentId).toBeGreaterThan(0); + + // Admin opens the item detail; the visibility toggle is offered. + await page.goto(`/workspaces/${channel.workspaceId}/items/${itemId}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 15_000 }); + const toggle = page.getByTestId(`item-attachment-visibility-${attachmentId}`); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + await expect(toggle).toHaveAttribute('aria-label', 'Hide from customer'); + + // Hiding the file flips the affordance to reveal. + const hidePromise = page.waitForResponse( + (resp) => + resp.url().includes(`/api/v2/attachments/${attachmentId}`) && + resp.request().method() === 'PATCH' + ); + await toggle.click(); + const hideResp = await hidePromise; + expect(hideResp.ok(), `hide: ${hideResp.status()}`).toBeTruthy(); + expect((await hideResp.json()).data.is_internal).toBe(true); + await expect(toggle).toHaveAttribute('aria-label', 'Show to customer'); + + // The portal timeline no longer offers the file. + await customerPage.reload(); + await expect(customerPage.getByTestId('portal-request-detail-title')).toHaveText(title, { + timeout: 5000, + }); + await expect( + customerPage.getByTestId('portal-request-attachment') + ).toHaveCount(0); + + // Revealing it brings the file back. + const revealPromise = page.waitForResponse( + (resp) => + resp.url().includes(`/api/v2/attachments/${attachmentId}`) && + resp.request().method() === 'PATCH' + ); + await toggle.click(); + const revealResp = await revealPromise; + expect(revealResp.ok(), `reveal: ${revealResp.status()}`).toBeTruthy(); + expect((await revealResp.json()).data.is_internal).toBe(false); + await expect(toggle).toHaveAttribute('aria-label', 'Hide from customer'); + + await customerPage.reload(); + await expect( + customerPage.getByTestId('portal-request-attachment') + ).toHaveCount(1, { timeout: 5000 }); + + // A plain agent-created item has no portal audience, so it shows no + // visibility affordance even for an editor. + const agentItem = await createItemViaAPI(request, channel.workspaceId, { + title: `Internal item ${stamp}`, + }); + const agentUpload = await request.post( + `/api/v2/items/${agentItem.id}/attachments`, + { + multipart: { + file: { + name: 'internal.png', + mimeType: 'image/png', + buffer: pngBuffer(), + }, + }, + } + ); + expect(agentUpload.ok()).toBeTruthy(); + const agentAttachmentId = (await agentUpload.json()).data.id; + + await page.goto(`/workspaces/${channel.workspaceId}/items/${agentItem.id}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('item-attachment')).toHaveCount(1, { timeout: 10_000 }); + await expect( + page.getByTestId(`item-attachment-visibility-${agentAttachmentId}`) + ).toHaveCount(0); + + await customerContext.close(); + }); +}); diff --git a/e2e/tests/board-bubble-sort.spec.ts b/e2e/tests/board-bubble-sort.spec.ts index f589712..4086de4 100644 --- a/e2e/tests/board-bubble-sort.spec.ts +++ b/e2e/tests/board-bubble-sort.spec.ts @@ -65,7 +65,8 @@ async function addComment( async function dragCardToCard( page: Page, card: Locator, - target: Locator + target: Locator, + edge: 'top' | 'bottom' = 'top' ): Promise { const cardBox = await card.boundingBox(); const targetBox = await target.boundingBox(); @@ -75,10 +76,11 @@ async function dragCardToCard( const startX = cardBox.x + cardBox.width / 2; const startY = cardBox.y + cardBox.height / 2; + const targetY = edge === 'top' ? targetBox.y + 12 : targetBox.y + targetBox.height - 4; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX + 12, startY + 12); - await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 12, { steps: 12 }); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetY, { steps: 12 }); await page.mouse.up(); } @@ -152,3 +154,52 @@ test.describe('Board Bubble Mode sort', () => { await expect.poll(firstCardId).toBe(String(oldest)); }); }); + +test.describe('Board rank reorder', () => { + test('reorders a card within its column and persists the rank', async ({ + page, + request, + }) => { + const workspace = await createWorkspaceViaAPI(request, generateWorkspace()); + const workspaceId: number = workspace.id; + const statuses = await listWorkspaceStatuses(request, workspaceId); + const openStatus = statuses.find((s) => s.name === 'Open') ?? statuses[0]; + + // Created sequentially so each seeds a distinct frac_index. + const ids: number[] = []; + for (let i = 1; i <= 3; i++) { + ids.push(await createItem(request, workspaceId, `Rank ${i}`, openStatus.id)); + } + const [first, second, third] = ids; + + const boardPage = new BoardPage(page); + await boardPage.goto(String(workspaceId)); + + const openColumn = page.locator( + `[data-status-column][data-status-id="${openStatus.id}"]` + ); + await expect(openColumn.locator('.board-card')).toHaveCount(3); + + const cardOrder = (): Promise> => + openColumn + .locator('.board-card') + .evaluateAll((cards) => cards.map((card) => card.getAttribute('data-item-id'))); + + // Rank mode is the default; the backend frac_index order is creation order. + await expect.poll(cardOrder).toEqual([String(first), String(second), String(third)]); + + // Drop the first card on the last card's bottom edge: it becomes last. + await dragCardToCard( + page, + page.getByTestId(`board-item-${first}`), + page.getByTestId(`board-item-${third}`), + 'bottom' + ); + + await expect.poll(cardOrder).toEqual([String(second), String(third), String(first)]); + + await page.reload(); + await expect(openColumn.locator('.board-card')).toHaveCount(3); + await expect.poll(cardOrder).toEqual([String(second), String(third), String(first)]); + }); +}); diff --git a/e2e/tests/board-bulk-labels.spec.ts b/e2e/tests/board-bulk-labels.spec.ts index 1344b94..bcfc214 100644 --- a/e2e/tests/board-bulk-labels.spec.ts +++ b/e2e/tests/board-bulk-labels.spec.ts @@ -1,3 +1,4 @@ +import type { Request } from "@playwright/test"; import { authenticateAdminRequest, createItemViaAPI, @@ -72,6 +73,20 @@ test.describe("Board bulk labels", () => { }); const labelName = `bulk-created-${suffix}`; + // The board must send the selection delta in one bulk request instead of + // one PUT per item; the counter proves no per-item fan-out remains. + const labelWrites: string[] = []; + const onRequest = (browserRequest: Request) => { + const path = new URL(browserRequest.url()).pathname; + if ( + path.endsWith("/items/bulk-labels") || + /\/items\/\d+\/labels$/.test(path) + ) { + labelWrites.push(`${browserRequest.method()} ${path}`); + } + }; + page.on("request", onRequest); + try { await page.goto(`/workspaces/${ws.id}/board`); const firstCard = page.getByTestId(`board-item-${first.id}`); @@ -104,6 +119,15 @@ test.describe("Board bulk labels", () => { "Updated labels on 2 items", ); await expect(modal).toHaveCount(0); + expect( + labelWrites.filter((write) => write.endsWith("/items/bulk-labels")), + ).toHaveLength(1); + expect( + labelWrites.filter( + (write) => + write.startsWith("PUT ") && /\/items\/\d+\/labels$/.test(write), + ), + ).toHaveLength(0); // Reload and reopen: both items still carry the label. await page.reload(); @@ -116,6 +140,7 @@ test.describe("Board bulk labels", () => { labelName, ); } finally { + page.off("request", onRequest); expect((await request.delete(`/api/v2/workspaces/${ws.id}`)).status()).toBe(204); } }); diff --git a/e2e/tests/board-configuration-columns.spec.ts b/e2e/tests/board-configuration-columns.spec.ts index 8017703..4540b94 100644 --- a/e2e/tests/board-configuration-columns.spec.ts +++ b/e2e/tests/board-configuration-columns.spec.ts @@ -144,6 +144,14 @@ test.describe('Board configuration columns', () => { await expect(page.getByTestId('board-completed-retention-days')).toHaveCount(0); await expect(page.getByTestId('board-rightmost-limit-enabled')).toHaveCount(0); + // Every preset renders its translated label instead of the raw i18n key. + const completedVisibility = page.getByTestId('board-completed-visibility'); + await expect(completedVisibility).toContainText('Show all'); + await expect(completedVisibility).toContainText('Last day'); + await expect(completedVisibility).toContainText('Last week'); + await expect(completedVisibility).toContainText('Limit to'); + await expect(completedVisibility).toContainText('Hide completed'); + await page.getByTestId('board-completed-visibility').selectOption('last_week'); const filteredItemsRequest = page.waitForRequest((candidate) => { @@ -181,5 +189,33 @@ test.describe('Board configuration columns', () => { config = (await configResponse.json()).data; expect(config.view_settings?.completed_visibility).toBe('all'); expect(config.completed_item_retention_days ?? null).toBeNull(); + + // The limit preset persists its cap, and the board's completed read asks + // for exactly that many items instead of paging further. + await page.goto(configureURL); + await page.getByTestId('board-completed-visibility').selectOption('limit'); + await page.getByTestId('board-completed-limit').fill('25'); + const cappedRequest = page.waitForRequest((candidate) => { + if (!candidate.url().includes('/api/v2/items?')) return false; + const params = new URL(candidate.url()).searchParams; + return params.get('status_id') !== null && params.get('page_size') === '25'; + }); + await page.getByTestId('board-config-save').click(); + await cappedRequest; + await page.waitForURL((url) => url.pathname === `/workspaces/${workspace.id}/board`); + + configResponse = await request.get( + `/api/v2/workspaces/${workspace.id}/board-configuration`, + { headers: SEC_FETCH } + ); + expect(configResponse.ok()).toBeTruthy(); + config = (await configResponse.json()).data; + expect(config.view_settings?.completed_visibility).toBe('limit'); + expect(config.view_settings?.completed_limit).toBe(25); + + // The preset and its cap round-trip into the controls. + await page.goto(configureURL); + await expect(page.getByTestId('board-completed-visibility')).toHaveValue('limit'); + await expect(page.getByTestId('board-completed-limit')).toHaveValue('25'); }); }); diff --git a/e2e/tests/board-iteration-filter.spec.ts b/e2e/tests/board-iteration-filter.spec.ts new file mode 100644 index 0000000..292dafe --- /dev/null +++ b/e2e/tests/board-iteration-filter.spec.ts @@ -0,0 +1,97 @@ +import { + createIterationViaAPI, + createWorkspaceViaAPI, + listIterationTypesViaAPI, +} from "../fixtures/api-helpers"; +import { + expect, + test, + type APIRequestContext, +} from "../fixtures/context-path"; +import { generateIteration, generateWorkspace } from "../fixtures/test-data"; +import { BoardPage } from "../pages/board.page"; + +/** + * The board iteration filter bar is only useful when there is an iteration to + * filter by or an active filter to clear. A board whose iterations are all + * completed used to render a lone, already-selected "All Items" tab that did + * nothing when clicked. + */ +test.describe("Board iteration filter", () => { + let iterationTypeId: number; + + test.beforeAll(async ({ request }) => { + const types = await listIterationTypesViaAPI(request); + if (types.length === 0) { + throw new Error("No iteration types seeded — backend seed changed?"); + } + iterationTypeId = types[0].id; + }); + + async function createWorkspaceWithIteration( + request: APIRequestContext, + status: "planned" | "completed", + ) { + const workspace = generateWorkspace(); + const created = await createWorkspaceViaAPI(request, { + name: workspace.name, + key: workspace.key, + description: workspace.description ?? "board iteration filter", + }); + const iteration = generateIteration(status); + await createIterationViaAPI(request, { + ...iteration, + workspace_id: created.id, + type_id: iterationTypeId, + status, + }); + return created.id as number; + } + + test("shows the filter bar when a non-completed iteration exists", async ({ + page, + request, + }) => { + const workspaceId = await createWorkspaceWithIteration(request, "planned"); + + const boardPage = new BoardPage(page); + await boardPage.goto(String(workspaceId)); + await boardPage.verifyBoardVisible(); + + await expect(page.getByTestId("board-iteration-filter")).toBeVisible(); + await expect( + page.getByTestId("board-iteration-filter-all"), + ).toBeVisible(); + }); + + test("hides the filter bar when every iteration is completed", async ({ + page, + request, + }) => { + // The personal workspace excludes global iterations, so its filter bar + // reflects only its own completed iteration. A fresh workspace would be + // non-deterministic here: global iterations are shared and other specs + // create them in parallel. + const personalResponse = await request.get("/api/workspaces/personal", { + headers: { "Sec-Fetch-Site": "same-origin" }, + }); + expect( + personalResponse.ok(), + `load personal workspace failed (${personalResponse.status()})`, + ).toBeTruthy(); + const personal = await personalResponse.json(); + const iteration = generateIteration("completed"); + await createIterationViaAPI(request, { + ...iteration, + workspace_id: personal.id, + type_id: iterationTypeId, + status: "completed", + }); + + const boardPage = new BoardPage(page); + await boardPage.goto(String(personal.id)); + await boardPage.verifyBoardVisible(); + + await expect(page.getByTestId("board-iteration-filter")).toHaveCount(0); + }); +}); diff --git a/e2e/tests/board-quick-add-hidden-card.spec.ts b/e2e/tests/board-quick-add-hidden-card.spec.ts new file mode 100644 index 0000000..300d7bf --- /dev/null +++ b/e2e/tests/board-quick-add-hidden-card.spec.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto"; +import { + createCollectionViaAPI, + createWorkspaceViaAPI, +} from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/context-path"; +import { generateWorkspace } from "../fixtures/test-data"; + +/** + * A card created while a collection filter hides it must still surface its + * item key and offer to open the item, so the user can fix the item (for + * example by adding the missing label) without leaving the collection. + */ +test("hidden quick-add card toast shows the item key and opens the item modal", async ({ + page, + request, +}) => { + const suffix = randomUUID().slice(0, 8); + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`hidden-${suffix}`), + ); + // A filter that matches nothing makes the freshly created card a non-member + // of the collection while the board itself stays usable. + const collection = await createCollectionViaAPI(request, { + name: `Hidden card ${suffix}`, + workspace_id: workspace.id, + ql_query: `title ~ "never-matches-${suffix}"`, + }); + + await page.goto(`/collections/${collection.id}/board`); + await expect(page.getByTestId("board-view")).toBeVisible(); + + await page.getByTestId(/^board-column-add-/).first().click(); + const title = `Hidden card ${suffix}`; + await page.getByTestId(/^quick-add-title-/).fill(title); + + const createResponsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/v2/items") && + response.request().method() === "POST", + ); + await page.getByTestId("quick-add-create").click(); + const createResponse = await createResponsePromise; + expect(createResponse.ok()).toBeTruthy(); + const item = (await createResponse.json()).data as { + id: number; + workspace_key: string; + workspace_item_number: number; + }; + const itemKey = `${item.workspace_key}-${item.workspace_item_number}`; + + const hiddenToast = page.getByTestId("toast").first(); + await expect(hiddenToast).toHaveAttribute("data-toast-variant", "info"); + await expect(hiddenToast).toContainText(itemKey); + await expect(page.getByTestId("toast-message-info")).toContainText( + "hidden by the collection filters", + ); + + await hiddenToast.click(); + await expect(page.getByTestId("item-detail-ready")).toBeVisible(); + await expect(page.getByTestId("item-detail")).toContainText(itemKey); +}); diff --git a/e2e/tests/board-transition-screen.spec.ts b/e2e/tests/board-transition-screen.spec.ts new file mode 100644 index 0000000..2d040a2 --- /dev/null +++ b/e2e/tests/board-transition-screen.spec.ts @@ -0,0 +1,380 @@ +import { + authenticateAdminRequest, + createItemViaAPI, + createWorkspaceViaAPI, +} from "../fixtures/api-helpers"; +import { + type APIRequestContext, + expect, + type Locator, + type Page, + test, +} from "../fixtures/context-path"; +import { generateItem, generateWorkspace } from "../fixtures/test-data"; + +/** + * Board moves that carry a transition screen. + * + * A status drop between columns must open the screen modal before moving, and + * the modal must seed the displayed fields from the item so the user does not + * retype the title or description. The modal is portaled above the board so a + * portaled dialog cannot hide it. + */ + +const SEC_FETCH = { "Sec-Fetch-Site": "same-origin" }; + +const MARKDOWN_DESCRIPTION = "### Steps to reproduce\n\n* Step 1"; + +interface Status { + id: number; + name: string; +} + +interface Workflow { + id: number; +} + +interface Transition { + id: number; + from?: { id: number }; + to: { id: number }; +} + +function unwrap(body: unknown): T { + const envelope = body as { data?: T } | undefined; + return (envelope?.data ?? body) as T; +} + +async function getItemStatus( + request: APIRequestContext, + itemId: number, +): Promise { + const resp = await request.get(`/api/v2/items/${itemId}`, { headers: SEC_FETCH }); + const body = unwrap<{ status_id: number | null }>(await resp.json()); + return body.status_id; +} + +async function listWorkspaceStatuses( + request: APIRequestContext, + workspaceId: number, +): Promise { + const resp = await request.get(`/api/v2/workspaces/${workspaceId}/statuses`, { + headers: SEC_FETCH, + }); + expect(resp.ok(), `list statuses failed (${resp.status()})`).toBeTruthy(); + return unwrap(await resp.json()); +} + +async function listWorkspaceWorkflows( + request: APIRequestContext, + workspaceId: number, +): Promise { + const resp = await request.get(`/api/v2/workspaces/${workspaceId}/workflows`, { + headers: SEC_FETCH, + }); + expect(resp.ok(), `list workflows failed (${resp.status()})`).toBeTruthy(); + return unwrap(await resp.json()); +} + +async function listWorkflowTransitions( + request: APIRequestContext, + workflowId: number, +): Promise { + const resp = await request.get(`/api/v2/workflows/${workflowId}/transitions`, { + headers: SEC_FETCH, + }); + expect(resp.ok(), `list transitions failed (${resp.status()})`).toBeTruthy(); + return unwrap(await resp.json()); +} + +async function bindTransitionScreen( + request: APIRequestContext, + transitionId: number, + screenId: number, +): Promise { + const resp = await request.put(`/api/v2/transitions/${transitionId}/screen`, { + headers: SEC_FETCH, + data: { screen_id: screenId }, + }); + expect( + resp.ok(), + `bind transition screen failed (${resp.status()}): ${await resp.text()}`, + ).toBeTruthy(); +} + +async function createTransitionScreen( + request: APIRequestContext, + name: string, +): Promise<{ id: number }> { + const createResp = await request.post("/api/v2/screens", { + headers: SEC_FETCH, + data: { name, description: "board transition screen e2e" }, + }); + expect( + createResp.ok(), + `create screen failed (${createResp.status()}): ${await createResp.text()}`, + ).toBeTruthy(); + const screen = unwrap<{ id: number }>(await createResp.json()); + + const fieldsResp = await request.put(`/api/v2/screens/${screen.id}/fields`, { + headers: SEC_FETCH, + data: { + fields: [ + { + field_type: "system", + field_identifier: "title", + display_order: 0, + is_required: true, + field_width: "full", + }, + { + field_type: "system", + field_identifier: "description", + display_order: 1, + is_required: false, + field_width: "full", + }, + ], + }, + }); + expect( + fieldsResp.ok(), + `set screen fields failed (${fieldsResp.status()}): ${await fieldsResp.text()}`, + ).toBeTruthy(); + return screen; +} + +async function expectMarkdownDescription(modal: Locator) { + const description = modal.getByTestId("transition-field-description"); + await expect(description).toBeVisible({ timeout: 15_000 }); + await expect(description).toHaveAttribute("data-ready", "true", { timeout: 15_000 }); + + const rendered = await description.evaluate((node) => ({ + heading: node.querySelector("h3")?.textContent ?? null, + listItems: Array.from(node.querySelectorAll("li")).map((li) => li.textContent?.trim()), + raw: node.textContent ?? "", + })); + expect(rendered.heading).toBe("Steps to reproduce"); + expect(rendered.listItems).toContain("Step 1"); + // The raw markers must be gone, proving the editor parsed the markdown. + expect(rendered.raw).not.toContain("###"); + expect(rendered.raw).not.toContain("* Step 1"); +} + +async function dragCardToColumn(page: Page, card: Locator, target: Locator) { + const cardBox = await card.boundingBox(); + const targetBox = await target.boundingBox(); + if (!cardBox || !targetBox) { + throw new Error("Board drag source and target must both be visible"); + } + const startX = cardBox.x + cardBox.width / 2; + const startY = cardBox.y + cardBox.height / 2; + const targetX = targetBox.x + targetBox.width / 2; + const targetY = targetBox.y + Math.min(targetBox.height / 2, 160); + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX + 12, startY + 12); + await page.mouse.move(targetX, targetY, { steps: 12 }); + await page.mouse.up(); +} + +test("item detail modal status change opens the transition screen above the modal", async ({ + page, + request, +}) => { + test.setTimeout(90_000); + await authenticateAdminRequest(request); + + const suffix = `its${Date.now()}`; + const ws = await createWorkspaceViaAPI(request, generateWorkspace(suffix)); + const statuses = await listWorkspaceStatuses(request, ws.id); + expect(statuses.length).toBeGreaterThanOrEqual(2); + + const [workflow] = await listWorkspaceWorkflows(request, ws.id); + expect(workflow, "fresh workspace has no workflow").toBeDefined(); + const transitions = await listWorkflowTransitions(request, workflow.id); + + const fromStatus = statuses[0]; + const toStatus = statuses[1]; + const transition = transitions.find( + (candidate) => candidate.from?.id === fromStatus.id && candidate.to.id === toStatus.id, + ); + expect( + transition, + `no transition ${fromStatus.id} -> ${toStatus.id} in workflow ${workflow.id}`, + ).toBeDefined(); + + const screen = await createTransitionScreen(request, `Item modal screen ${suffix}`); + await bindTransitionScreen(request, transition!.id, screen.id); + + const itemData = generateItem(ws.id, suffix); + const item = await createItemViaAPI(request, ws.id, { + title: itemData.title, + description: MARKDOWN_DESCRIPTION, + }); + + await page.goto(`/workspaces/${ws.id}/board`); + await expect(page.getByTestId("board-view")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId(`board-item-${item.id}`).click(); + + const dialog = page.getByTestId("item-detail-modal"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // The status picker lives in the modal sidebar; its options are keyed by the + // target status id so the selection is stable across translations. + await dialog.getByTestId("status-field").click(); + await page.getByTestId(`item-detail-status-option-${toStatus.id}`).click(); + + const modal = page.getByTestId("transition-screen-modal"); + await expect(modal).toBeVisible({ timeout: 10_000 }); + await expect(modal.getByTestId("transition-field-title")).toHaveValue(itemData.title); + await expectMarkdownDescription(modal); + + await modal.getByTestId("dialog-confirm").click(); + + await expect + .poll( + async () => { + const resp = await request.get(`/api/v2/items/${item.id}`, { headers: SEC_FETCH }); + const body = unwrap<{ status_id: number | null }>(await resp.json()); + return body.status_id; + }, + { message: `item ${item.id} did not transition to ${toStatus.id}`, timeout: 15_000 }, + ) + .toBe(toStatus.id); +}); + +test("keyboard shortcuts submit and cancel the transition screen", async ({ + page, + request, +}) => { + test.setTimeout(90_000); + await authenticateAdminRequest(request); + + const suffix = `tsk${Date.now()}`; + const ws = await createWorkspaceViaAPI(request, generateWorkspace(suffix)); + const statuses = await listWorkspaceStatuses(request, ws.id); + expect(statuses.length).toBeGreaterThanOrEqual(2); + + const [workflow] = await listWorkspaceWorkflows(request, ws.id); + expect(workflow, "fresh workspace has no workflow").toBeDefined(); + const transitions = await listWorkflowTransitions(request, workflow.id); + + const fromStatus = statuses[0]; + const toStatus = statuses[1]; + const transition = transitions.find( + (candidate) => candidate.from?.id === fromStatus.id && candidate.to.id === toStatus.id, + ); + expect( + transition, + `no transition ${fromStatus.id} -> ${toStatus.id} in workflow ${workflow.id}`, + ).toBeDefined(); + + const screen = await createTransitionScreen(request, `Keyboard screen ${suffix}`); + await bindTransitionScreen(request, transition!.id, screen.id); + + const itemData = generateItem(ws.id, suffix); + const item = await createItemViaAPI(request, ws.id, { + title: itemData.title, + description: MARKDOWN_DESCRIPTION, + }); + + await page.goto(`/workspaces/${ws.id}/board`); + await expect(page.getByTestId("board-view")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId(`board-item-${item.id}`).click(); + + const dialog = page.getByTestId("item-detail-modal"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const openScreen = async () => { + await dialog.getByTestId("status-field").click(); + await page.getByTestId(`item-detail-status-option-${toStatus.id}`).click(); + const modal = page.getByTestId("transition-screen-modal"); + await expect(modal).toBeVisible({ timeout: 10_000 }); + return modal; + }; + + // Escape cancels the screen without moving the item, and the footer exposes + // the same shortcut hints as every other modal. + let modal = await openScreen(); + await expect(modal.getByTestId("dialog-confirm")).toContainText("â†ĩ"); + await page.keyboard.press("Escape"); + await expect(modal).toBeHidden({ timeout: 10_000 }); + expect(await getItemStatus(request, item.id)).toBe(fromStatus.id); + + // The submit shortcut moves the item. + modal = await openScreen(); + await page.keyboard.press("Control+Enter"); + await expect(modal).toBeHidden({ timeout: 10_000 }); + await expect + .poll(async () => getItemStatus(request, item.id), { + message: `item ${item.id} did not transition to ${toStatus.id}`, + timeout: 15_000, + }) + .toBe(toStatus.id); +}); + +test("board drop opens the transition screen pre-filled from the item", async ({ + page, + request, +}) => { + test.setTimeout(90_000); + await authenticateAdminRequest(request); + + const suffix = `bts${Date.now()}`; + const ws = await createWorkspaceViaAPI(request, generateWorkspace(suffix)); + const statuses = await listWorkspaceStatuses(request, ws.id); + expect(statuses.length).toBeGreaterThanOrEqual(2); + + const [workflow] = await listWorkspaceWorkflows(request, ws.id); + expect(workflow, "fresh workspace has no workflow").toBeDefined(); + const transitions = await listWorkflowTransitions(request, workflow.id); + + const fromStatus = statuses[0]; + const toStatus = statuses[1]; + const transition = transitions.find( + (candidate) => candidate.from?.id === fromStatus.id && candidate.to.id === toStatus.id, + ); + expect( + transition, + `no transition ${fromStatus.id} -> ${toStatus.id} in workflow ${workflow.id}`, + ).toBeDefined(); + + const screen = await createTransitionScreen(request, `Board screen ${suffix}`); + await bindTransitionScreen(request, transition!.id, screen.id); + + const itemData = generateItem(ws.id, suffix); + const item = await createItemViaAPI(request, ws.id, { + title: itemData.title, + description: MARKDOWN_DESCRIPTION, + }); + + await page.goto(`/workspaces/${ws.id}/board`); + await expect(page.getByTestId("board-view")).toBeVisible({ timeout: 15_000 }); + + const card = page.getByTestId(`board-item-${item.id}`); + const targetColumn = page.locator(`#board-column-status-${toStatus.id}`); + await expect(card).toHaveAttribute("draggable", "true", { timeout: 5_000 }); + await expect(targetColumn).toBeVisible(); + + await dragCardToColumn(page, card, targetColumn); + + // The drop must not move the item silently; it opens the screen instead. + const modal = page.getByTestId("transition-screen-modal"); + await expect(modal).toBeVisible({ timeout: 10_000 }); + await expect(modal.getByTestId("transition-field-title")).toHaveValue(itemData.title); + await expectMarkdownDescription(modal); + + await modal.getByTestId("dialog-confirm").click(); + + await expect + .poll( + async () => { + const resp = await request.get(`/api/v2/items/${item.id}`, { headers: SEC_FETCH }); + const body = unwrap<{ status_id: number | null }>(await resp.json()); + return body.status_id; + }, + { message: `item ${item.id} did not transition to ${toStatus.id}`, timeout: 15_000 }, + ) + .toBe(toStatus.id); +}); diff --git a/e2e/tests/button-smoke.spec.ts b/e2e/tests/button-smoke.spec.ts index 40cf1fd..4168a18 100644 --- a/e2e/tests/button-smoke.spec.ts +++ b/e2e/tests/button-smoke.spec.ts @@ -28,11 +28,8 @@ const DIALOG = 'div[role="dialog"]'; function allowEnvironmentalNoise(allow: (pattern: RegExp) => void) { // Rate limiter fires 429s on rapid navigation during a smoke run. allow(/Failed to load resource.*429 \(Too Many Requests\)/); - // Optional services (logbook container, optional attachment-settings backend) - // are not mounted in every deployment; the frontend logs a 404 when absent. - allow(/\/api\/logbook\//); - allow(/Failed to load buckets/); - allow(/Failed to load all documents/); + // The optional attachment-settings backend is not mounted in every + // deployment; the frontend logs a 404 when absent. allow(/\/api\/attachment-settings\/status/); allow(/Failed to load attachment status/); } diff --git a/e2e/tests/chat-action-context.spec.ts b/e2e/tests/chat-action-context.spec.ts index 71ad181..43a298d 100644 --- a/e2e/tests/chat-action-context.spec.ts +++ b/e2e/tests/chat-action-context.spec.ts @@ -22,9 +22,7 @@ test.describe('Chat picks up the action editor context from the URL', () => { test('sends action_id when chatting from /workspaces/:id/actions/:actionId', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const ws = await createWorkspaceViaAPI(request, { diff --git a/e2e/tests/chat-action-live-reload.spec.ts b/e2e/tests/chat-action-live-reload.spec.ts index 764c33f..10b1dce 100644 --- a/e2e/tests/chat-action-live-reload.spec.ts +++ b/e2e/tests/chat-action-live-reload.spec.ts @@ -42,9 +42,7 @@ test.describe('Chat live-reload of agent-driven action edits', () => { test('editor refetches when agentRuns signals an agent completion', async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); await page.route('**/api/shell-bootstrap', async (route) => { const response = await route.fetch(); diff --git a/e2e/tests/chat-item-live-reload.spec.ts b/e2e/tests/chat-item-live-reload.spec.ts index fbcbfce..aabc0b0 100644 --- a/e2e/tests/chat-item-live-reload.spec.ts +++ b/e2e/tests/chat-item-live-reload.spec.ts @@ -28,7 +28,6 @@ test.describe('Chat live-reload of work item detail', () => { request, allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); // Item-detail load always probes /api/items/:id/recurrence; a 404 here // means "no recurrence rule configured", which is the normal case. allowConsoleError(/\/api\/items\/\d+\/recurrence/); diff --git a/e2e/tests/collection-ancestor-context.spec.ts b/e2e/tests/collection-ancestor-context.spec.ts index 9e4acec..fb180d8 100644 --- a/e2e/tests/collection-ancestor-context.spec.ts +++ b/e2e/tests/collection-ancestor-context.spec.ts @@ -105,21 +105,20 @@ test('keeps a completed parent visible as context when hide completed filters it await page.goto(`${base}/tree`); const toggle = page.getByTestId('collection-hide-completed'); await expect(toggle).toHaveAttribute('aria-checked', 'true'); - // The completed epic is filtered from the page but still renders as the - // child's context parent — with its real status, not "No status". - const epicRow = page.getByTestId(`tree-row-${done.id}`); - await expect(epicRow).toContainText(`Finished epic ${stamp}`); - // The context epic carries its real Done status, not "No status". - await expect(epicRow).toContainText(`Released ${stamp}`); - await expect(epicRow).not.toContainText('No status'); + // The completed epic is filtered from the page. The active child is a base + // row, and the filtered-out parent surfaces as a parent reference chip + // instead of a synthetic ancestor row (GH #271). await expect(page.getByTestId(`tree-row-${open.id}`)).toContainText(`Follow-up bug ${stamp}`); + const parentChip = page.getByTestId(`tree-parent-${open.id}`); + await expect(parentChip).toBeVisible(); + await expect(parentChip).toContainText(`${workspace.key}-${done.workspace_item_number}`); await page.goto(`${base}/roadmap`); await expect(page.getByTestId('roadmap-view')).toBeVisible(); - // The epic must not vanish on the roadmap either; its active child is - // shown beneath it instead of floating as a stray root. - await expect(page.getByTestId(`roadmap-item-${done.id}`)).toContainText(`Finished epic ${stamp}`); + // The active child is a root at its level; the completed parent is above + // the picked level and is no longer drawn as an ancestor row. await expect(page.getByTestId(`roadmap-item-${open.id}`)).toContainText(`Follow-up bug ${stamp}`); + await expect(page.getByTestId(`roadmap-item-${done.id}`)).toHaveCount(0); } finally { for (const path of cleanup) { const response = await request.delete(path, { headers }); diff --git a/e2e/tests/collection-base-url-redirect.spec.ts b/e2e/tests/collection-base-url-redirect.spec.ts index 29f52d3..ac6a466 100644 --- a/e2e/tests/collection-base-url-redirect.spec.ts +++ b/e2e/tests/collection-base-url-redirect.spec.ts @@ -16,7 +16,7 @@ test("collection base URL keeps its collection through the default-view redirect page, request, }) => { - const stamp = Date.now(); + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const workspace = await createWorkspaceViaAPI(request, { name: `Collection redirect ${stamp}`, key: `CR${stamp.toString().slice(-6)}`.toUpperCase(), diff --git a/e2e/tests/collection-board-sse-live.spec.ts b/e2e/tests/collection-board-sse-live.spec.ts index 85ff700..adb2b16 100644 --- a/e2e/tests/collection-board-sse-live.spec.ts +++ b/e2e/tests/collection-board-sse-live.spec.ts @@ -17,7 +17,6 @@ test.describe("collection board live updates via SSE (WI-1624)", () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const suffix = `board-sse-${randomUUID().slice(0, 8)}${testInfo.workerIndex}`; diff --git a/e2e/tests/collection-completed-visibility.spec.ts b/e2e/tests/collection-completed-visibility.spec.ts index 502ae13..76d77d5 100644 --- a/e2e/tests/collection-completed-visibility.spec.ts +++ b/e2e/tests/collection-completed-visibility.spec.ts @@ -71,14 +71,17 @@ test('hides custom completed categories and remembers visibility separately for }, false ); + // The tree is level-scoped, so the visible item must sit at the same level + // the picker resolves. A completed child under an active parent keeps the + // hierarchy exercised while the parent stays visible when the child hides. + const open = await createItemViaAPI(request, workspace.id, { + title: `Active parent ${stamp}`, + }); const done = await createItemViaAPI(request, workspace.id, { - title: `Released parent ${stamp}`, + title: `Released child ${stamp}`, + parent_id: open.id, status_id: completed.id, }); - const open = await createItemViaAPI(request, workspace.id, { - title: `Active child ${stamp}`, - parent_id: done.id, - }); const base = `/workspaces/${workspace.id}`; await page.goto(`${base}/list`); diff --git a/e2e/tests/collection-filter-menu-active.spec.ts b/e2e/tests/collection-filter-menu-active.spec.ts new file mode 100644 index 0000000..b284dd1 --- /dev/null +++ b/e2e/tests/collection-filter-menu-active.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "../fixtures/context-path"; +import { + createItemViaAPI, + createWorkspaceViaAPI, +} from "../fixtures/api-helpers"; +import { generateWorkspace } from "../fixtures/test-data"; + +/** + * The unified Filter menu must surface the applied filter, let the user clear + * it, and clear it when the saved filter it came from is deleted. Before the + * fix the menu listed saved filters only: the applied state was invisible and + * deleting the saved filter left a filter that could not be unselected. + */ +test("filter menu shows the applied filter and clears it when its saved filter is deleted", async ({ + page, + request, +}) => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`filter-active-${stamp}`), + ); + + const needle = await createItemViaAPI(request, workspace.id, { + title: `Needle ${stamp}`, + }); + const other = await createItemViaAPI(request, workspace.id, { + title: `Other ${stamp}`, + }); + const needleKey = `${workspace.key}-${needle.workspace_item_number}`; + + await page.goto(`/workspaces/${workspace.id}/list`); + await expect(page.getByTestId("list-view")).toBeVisible(); + const needleRow = page.getByTestId(`workspace-item-row-${needle.id}`); + const otherRow = page.getByTestId(`workspace-item-row-${other.id}`); + await expect(needleRow).toBeVisible(); + await expect(otherRow).toBeVisible(); + + // Build and apply a Key filter through the builder. + await page.getByTestId("dynamic-filter-toggle").click(); + await page.getByTestId("filter-menu-add").click(); + await page.getByTestId("field-selector-trigger").click(); + await page.getByTestId("field-option-key").click(); + await page.getByTestId("collection-dynamic-filter-0-value").click(); + await page + .getByTestId("collection-dynamic-filter-0-value-input") + .fill(needleKey); + await page.getByTestId("collection-dynamic-filter-0-apply-value").click(); + + await expect(needleRow).toBeVisible(); + await expect(otherRow).toHaveCount(0); + + // The applied filter is visible in the menu and can be cleared. + await page.getByTestId("dynamic-filter-toggle").click(); + await expect(page.getByTestId("filter-menu-active")).toBeVisible(); + await expect(page.getByTestId("filter-menu-active-row-0")).toContainText( + needleKey, + ); + + // Save it as a personal filter, which keeps it applied. + await page.getByTestId("filter-menu-save").click(); + await page.getByTestId("filter-builder-name").fill(`Saved ${stamp}`); + await page.getByTestId("filter-builder-save").click(); + + await page.getByTestId("dynamic-filter-toggle").click(); + const savedRow = page.locator('[data-testid^="filter-menu-personal-apply-"]'); + await expect(savedRow).toBeVisible(); + await expect( + page.locator('[data-testid^="filter-menu-personal-applied-"]'), + ).toBeVisible(); + + // Deleting the applied saved filter clears the live filter too. + await page + .locator('[data-testid^="filter-menu-personal-delete-"]') + .first() + .click(); + await expect(page.getByTestId("filter-menu-active")).toHaveCount(0); + await expect(needleRow).toBeVisible(); + await expect(otherRow).toBeVisible(); +}); diff --git a/e2e/tests/collection-hierarchy-views.spec.ts b/e2e/tests/collection-hierarchy-views.spec.ts index 361ccb2..7071b9e 100644 --- a/e2e/tests/collection-hierarchy-views.spec.ts +++ b/e2e/tests/collection-hierarchy-views.spec.ts @@ -1,26 +1,41 @@ +import type { APIRequestContext } from "@playwright/test"; import { createCollectionViaAPI, createItemViaAPI, createWorkspaceViaAPI, + listItemTypesViaAPI, } from "../fixtures/api-helpers"; import { expect, test } from "../fixtures/errors"; +// The tree is rooted at one item-type level, so a fixture hierarchy must use +// adjacent item types (Epic -> Story -> Task); untyped items all default to +// level 0 and would render as siblings. +async function hierarchyItemTypes(request: APIRequestContext) { + const itemTypes = await listItemTypesViaAPI(request); + const byKey = (key: string) => + itemTypes.find((type: { builtin_key?: string }) => type.builtin_key === key); + return { epic: byKey("epic"), story: byKey("story"), task: byKey("task") }; +} + test("navigates and drills into the same hierarchy across tree, map, and roadmap", async ({ page, request, }) => { - const stamp = Date.now(); + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const workspace = await createWorkspaceViaAPI(request, { name: `Hierarchy views ${stamp}`, key: `HV${stamp.toString().slice(-6)}`.toUpperCase(), description: "Playwright collection hierarchy coverage", }); + const types = await hierarchyItemTypes(request); const parent = await createItemViaAPI(request, workspace.id, { title: `Parent initiative ${stamp}`, + item_type_id: types.epic.id, }); const child = await createItemViaAPI(request, workspace.id, { title: `Child delivery ${stamp}`, parent_id: parent.id, + item_type_id: types.story.id, }); const collection = await createCollectionViaAPI(request, { name: `Hierarchy collection ${stamp}`, @@ -71,26 +86,70 @@ test("navigates and drills into the same hierarchy across tree, map, and roadmap await expect(modal.getByTestId("item-title-edit")).toHaveText(child.title); }); +test("roadmap expand-all reveals a collapsed subtree", async ({ + page, + request, +}) => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const workspace = await createWorkspaceViaAPI(request, { + name: `Roadmap expand ${stamp}`, + key: `RE${stamp.toString().slice(-6)}`.toUpperCase(), + description: "Playwright roadmap expand-all coverage", + }); + const types = await hierarchyItemTypes(request); + const epic = await createItemViaAPI(request, workspace.id, { + title: `Expand epic ${stamp}`, + item_type_id: types.epic.id, + }); + const story = await createItemViaAPI(request, workspace.id, { + title: `Expand story ${stamp}`, + parent_id: epic.id, + item_type_id: types.story.id, + }); + const task = await createItemViaAPI(request, workspace.id, { + title: `Expand task ${stamp}`, + parent_id: story.id, + item_type_id: types.task.id, + }); + + await page.goto(`/workspaces/${workspace.id}/roadmap`); + await expect(page.getByTestId("roadmap-view")).toBeVisible(); + await expect(page.getByTestId(`roadmap-item-${story.id}`)).toBeVisible(); + // The task starts collapsed under the story; roots auto-expand, so the first + // click collapses everything and the second expands the whole tree. + await expect(page.getByTestId(`roadmap-item-${task.id}`)).toHaveCount(0); + + await page.getByTestId("roadmap-expand-toggle").click(); + await expect(page.getByTestId(`roadmap-item-${story.id}`)).toHaveCount(0); + + await page.getByTestId("roadmap-expand-toggle").click(); + await expect(page.getByTestId(`roadmap-item-${task.id}`)).toBeVisible(); +}); + test("indents nested tree rows by hierarchy depth", async ({ page, request, }) => { - const stamp = Date.now(); + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const workspace = await createWorkspaceViaAPI(request, { name: `Tree indent ${stamp}`, key: `TI${stamp.toString().slice(-6)}`.toUpperCase(), description: "Playwright tree indentation coverage", }); + const types = await hierarchyItemTypes(request); const parent = await createItemViaAPI(request, workspace.id, { title: `Indent parent ${stamp}`, + item_type_id: types.epic.id, }); const child = await createItemViaAPI(request, workspace.id, { title: `Indent child ${stamp}`, parent_id: parent.id, + item_type_id: types.story.id, }); const grandchild = await createItemViaAPI(request, workspace.id, { title: `Indent grandchild ${stamp}`, parent_id: child.id, + item_type_id: types.task.id, }); const collection = await createCollectionViaAPI(request, { name: `Indent collection ${stamp}`, @@ -123,28 +182,86 @@ test("indents nested tree rows by hierarchy depth", async ({ expect(secondStep).toBeCloseTo(firstStep, 1); }); +test("tree expansion survives a reload", async ({ page, request }) => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const workspace = await createWorkspaceViaAPI(request, { + name: `Tree persistence ${stamp}`, + key: `TP${stamp.toString().slice(-6)}`.toUpperCase(), + description: "Playwright tree expansion persistence coverage", + }); + const types = await hierarchyItemTypes(request); + const epic = await createItemViaAPI(request, workspace.id, { + title: `Persist epic ${stamp}`, + item_type_id: types.epic.id, + }); + const story = await createItemViaAPI(request, workspace.id, { + title: `Persist story ${stamp}`, + parent_id: epic.id, + item_type_id: types.story.id, + }); + const task = await createItemViaAPI(request, workspace.id, { + title: `Persist task ${stamp}`, + parent_id: story.id, + item_type_id: types.task.id, + }); + const collection = await createCollectionViaAPI(request, { + name: `Persist collection ${stamp}`, + workspace_id: workspace.id, + ql_query: `title ~ "${stamp}"`, + }); + + const url = `/workspaces/${workspace.id}/collections/${collection.id}/tree`; + await page.goto(url); + await expect(page.getByTestId(`tree-row-${epic.id}`)).toBeVisible(); + // Roots start expanded, so the story is visible and the task is collapsed. + await expect(page.getByTestId(`tree-row-${story.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-row-${task.id}`)).toHaveCount(0); + + // Wait for the debounced view-state PATCH that carries the expansion, not + // the level save that may still be in flight from mount. + const saved = page.waitForResponse((response) => { + if ( + !response.url().includes("/api/v2/users/me/preferences") || + response.request().method() !== "PATCH" + ) { + return false; + } + return (response.request().postData() ?? "").includes("expanded_ids"); + }); + await page.getByTestId(`tree-expand-${story.id}`).click(); + await expect(page.getByTestId(`tree-row-${task.id}`)).toBeVisible(); + await saved; + + await page.reload(); + await expect(page.getByTestId(`tree-row-${story.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-row-${task.id}`)).toBeVisible(); +}); + test("dragging at the map edge scrolls the hierarchy horizontally", { tag: "@critical-browser", }, async ({ page, request }) => { await page.setViewportSize({ width: 800, height: 720 }); - const stamp = Date.now(); + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const workspace = await createWorkspaceViaAPI(request, { name: `Map drag scroll ${stamp}`, key: `MS${stamp.toString().slice(-6)}`.toUpperCase(), description: "Playwright map drag-scroll coverage", }); + const types = await hierarchyItemTypes(request); const parents = []; for (let index = 0; index < 7; index += 1) { parents.push( await createItemViaAPI(request, workspace.id, { title: `Map parent ${index} ${stamp}`, + item_type_id: types.epic.id, }), ); } const child = await createItemViaAPI(request, workspace.id, { title: `Map draggable child ${stamp}`, parent_id: parents[0].id, + item_type_id: types.story.id, }); await page.goto(`/workspaces/${workspace.id}/map`); diff --git a/e2e/tests/collection-level-picker.spec.ts b/e2e/tests/collection-level-picker.spec.ts new file mode 100644 index 0000000..e63f43f --- /dev/null +++ b/e2e/tests/collection-level-picker.spec.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto"; +import { + createItemViaAPI, + createWorkspaceViaAPI, + listItemTypesViaAPI, + listWorkspaceItemTypesViaAPI, +} from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/context-path"; +import { generateWorkspace } from "../fixtures/test-data"; + +type ItemType = { id: number; name: string; hierarchy_level: number }; + +/** + * The level picker is the control that scopes tree/map/roadmap to one item-type + * level. It had no browser coverage, which is how the map's add card could + * break at level 0 without a test noticing. + */ +test("level picker scopes the map, shows counts, and persists the choice", async ({ + page, + request, +}) => { + const suffix = randomUUID().slice(0, 8); + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`level-picker-${suffix}`), + ); + const globalTypes = (await listItemTypesViaAPI(request)) as ItemType[]; + const workspaceTypes = (await listWorkspaceItemTypesViaAPI( + request, + workspace.id, + )) as ItemType[]; + const byLevel = (level: number) => + workspaceTypes.find((type) => type.hierarchy_level === level); + const initiativeType = globalTypes.find( + (type) => type.hierarchy_level === 0, + ); + if (!initiativeType) throw new Error("No level-0 item type in the catalog"); + + // The default workspace configuration set excludes Initiative, so this + // level-0 type is deliberately not workspace-bound. + expect(workspaceTypes.some((type) => type.id === initiativeType.id)).toBe( + false, + ); + + const initiative = await createItemViaAPI(request, workspace.id, { + title: `Level initiative ${suffix}`, + item_type_id: initiativeType.id, + }); + const epic = await createItemViaAPI(request, workspace.id, { + title: `Level epic ${suffix}`, + item_type_id: byLevel(1)?.id, + }); + await createItemViaAPI(request, workspace.id, { + title: `Level story ${suffix}`, + item_type_id: byLevel(2)?.id, + parent_id: epic.id, + }); + + await page.goto(`/workspaces/${workspace.id}/map`); + await expect(page.getByTestId("map-view")).toBeVisible(); + + // Level 0 is the shallowest level with matches, so it is the default view. + const trigger = page.getByTestId("hierarchy-level-trigger"); + await expect(trigger).toContainText(initiativeType.name); + await expect( + page.getByTestId(`map-backbone-item-${initiative.id}`), + ).toBeVisible(); + await expect(page.getByTestId(`map-backbone-item-${epic.id}`)).toHaveCount( + 0, + ); + + // Every level is offered with its filtered count. + await trigger.click(); + await expect(page.getByTestId("hierarchy-level-option-0")).toContainText( + "1 item", + ); + await expect(page.getByTestId("hierarchy-level-option-1")).toContainText( + "1 item", + ); + + // Switching levels re-roots the backbone. + const levelSaved = page.waitForResponse((response) => { + if ( + !response.url().includes("/api/v2/users/me/preferences") || + response.request().method() !== "PATCH" + ) { + return false; + } + return (response.request().postData() ?? "").includes("hierarchy_level"); + }); + await page.getByTestId("hierarchy-level-option-1").click(); + await expect( + page.getByTestId(`map-backbone-item-${epic.id}`), + ).toBeVisible(); + await expect( + page.getByTestId(`map-backbone-item-${initiative.id}`), + ).toHaveCount(0); + + // The choice is persisted per user, so a reload keeps it. Wait for the + // debounced preferences PATCH before reloading. + await levelSaved; + await page.reload(); + await expect(page.getByTestId("map-view")).toBeVisible(); + await expect(page.getByTestId("hierarchy-level-trigger")).toContainText( + "Epic", + ); + await expect( + page.getByTestId(`map-backbone-item-${epic.id}`), + ).toBeVisible(); +}); diff --git a/e2e/tests/collection-list-tree-sse-delete.spec.ts b/e2e/tests/collection-list-tree-sse-delete.spec.ts index cb0c7a2..a53ea37 100644 --- a/e2e/tests/collection-list-tree-sse-delete.spec.ts +++ b/e2e/tests/collection-list-tree-sse-delete.spec.ts @@ -19,7 +19,6 @@ for (const view of ["list", "tree"] as const) { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const suffix = `${view}-sse-${randomUUID().slice(0, 8)}${testInfo.workerIndex}`; diff --git a/e2e/tests/collection-map-create-item.spec.ts b/e2e/tests/collection-map-create-item.spec.ts index b87c055..aaab997 100644 --- a/e2e/tests/collection-map-create-item.spec.ts +++ b/e2e/tests/collection-map-create-item.spec.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { createWorkspaceViaAPI, + listItemTypesViaAPI, listWorkspaceItemTypesViaAPI, } from "../fixtures/api-helpers"; import { type APIRequestContext, expect, test } from "../fixtures/context-path"; @@ -99,3 +100,50 @@ test("map quick-add creates and persists a child item", async ({ childTitle, ); }); + +// The default workspace configuration set intentionally excludes the level-0 +// Initiative type. A workspace can still hold Initiative items (imports, API), +// and the level picker then offers level 0 from the filtered counts. The add +// card must work there too: the parent type is not in the workspace-bound +// catalog, so its level has to come from the selected hierarchy level. +test("map add card works when the parent type is not workspace-bound", async ({ + page, + request, +}) => { + const suffix = `map-unbound-${randomUUID().slice(0, 8)}`; + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(suffix), + ); + const globalTypes = (await listItemTypesViaAPI(request)) as ItemType[]; + const levelZeroType = globalTypes.find( + (candidate) => candidate.hierarchy_level === 0, + ); + if (!levelZeroType) throw new Error("No level-0 item type in the catalog"); + // The type must not be workspace-bound for this to reproduce the bug. + const workspaceTypes = (await listWorkspaceItemTypesViaAPI( + request, + workspace.id, + )) as ItemType[]; + expect(workspaceTypes.some((type) => type.id === levelZeroType.id)).toBe( + false, + ); + + const parent = await createParent( + request, + workspace.id, + levelZeroType.id, + `${suffix} initiative`, + ); + + await page.goto(`/workspaces/${workspace.id}/map`); + await expect(page.getByTestId("map-view")).toBeVisible(); + // Level 0 is the shallowest level with matches, so it is the default view. + await expect( + page.getByTestId(`map-backbone-item-${parent.id}`), + ).toBeVisible(); + await page.getByTestId(`map-add-card-${parent.id}`).click(); + await expect( + page.getByTestId(`quick-add-title-${parent.id}`), + ).toBeVisible(); +}); diff --git a/e2e/tests/collection-map-sse-live.spec.ts b/e2e/tests/collection-map-sse-live.spec.ts index 3eb107e..284086b 100644 --- a/e2e/tests/collection-map-sse-live.spec.ts +++ b/e2e/tests/collection-map-sse-live.spec.ts @@ -19,9 +19,7 @@ test.describe("collection map live updates via SSE (WI-1624)", () => { test("a deletion pushed from elsewhere removes the map card without reloading", async ({ page, request, - allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); const suffix = `map-sse-${randomUUID().slice(0, 8)}${testInfo.workerIndex}`; const workspace = await createWorkspaceViaAPI( diff --git a/e2e/tests/collection-roadmap-sse-live.spec.ts b/e2e/tests/collection-roadmap-sse-live.spec.ts index 602abf0..231ec10 100644 --- a/e2e/tests/collection-roadmap-sse-live.spec.ts +++ b/e2e/tests/collection-roadmap-sse-live.spec.ts @@ -15,9 +15,7 @@ test.describe("collection roadmap live updates via SSE (WI-1624)", () => { test("a deletion pushed from elsewhere removes the roadmap row without reloading", async ({ page, request, - allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); const suffix = `roadmap-sse-${randomUUID().slice(0, 8)}${testInfo.workerIndex}`; const workspace = await createWorkspaceViaAPI( diff --git a/e2e/tests/collection-tree-controls.spec.ts b/e2e/tests/collection-tree-controls.spec.ts new file mode 100644 index 0000000..10e9d62 --- /dev/null +++ b/e2e/tests/collection-tree-controls.spec.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { + createItemViaAPI, + createWorkspaceViaAPI, + listWorkspaceItemTypesViaAPI, +} from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/context-path"; +import { generateWorkspace } from "../fixtures/test-data"; + +type ItemType = { id: number; hierarchy_level: number }; + +/** + * The tree's Expand All / Show Tests controls had no stable selectors and no + * browser coverage, so a regression there was invisible. + */ +test("tree controls expand the whole tree and persist the test-case toggle", async ({ + page, + request, +}) => { + const suffix = randomUUID().slice(0, 8); + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`tree-controls-${suffix}`), + ); + const workspaceTypes = (await listWorkspaceItemTypesViaAPI( + request, + workspace.id, + )) as ItemType[]; + const byLevel = (level: number) => + workspaceTypes.find((type) => type.hierarchy_level === level); + + const epic = await createItemViaAPI(request, workspace.id, { + title: `Tree epic ${suffix}`, + item_type_id: byLevel(1)?.id, + }); + const story = await createItemViaAPI(request, workspace.id, { + title: `Tree story ${suffix}`, + item_type_id: byLevel(2)?.id, + parent_id: epic.id, + }); + const task = await createItemViaAPI(request, workspace.id, { + title: `Tree task ${suffix}`, + item_type_id: byLevel(3)?.id, + parent_id: story.id, + }); + + await page.goto(`/workspaces/${workspace.id}/tree`); + await expect(page.getByTestId("tree-view")).toBeVisible(); + await expect(page.getByTestId(`tree-row-${epic.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-row-${story.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-row-${task.id}`)).toHaveCount(0); + + // Roots auto-expand, so the first click collapses the whole tree. + const expandToggle = page.getByTestId("tree-expand-toggle"); + await expandToggle.click(); + await expect(page.getByTestId(`tree-row-${story.id}`)).toHaveCount(0); + + // The second click expands every loaded level. + await expandToggle.click(); + await expect(page.getByTestId(`tree-row-${task.id}`)).toBeVisible(); + + // The test-case toggle is persisted per user. + const showTests = page.getByTestId("tree-show-tests"); + await expect(showTests).toHaveAttribute("aria-pressed", "false"); + const toggleSaved = page.waitForResponse((response) => { + if ( + !response.url().includes("/api/v2/users/me/preferences") || + response.request().method() !== "PATCH" + ) { + return false; + } + return (response.request().postData() ?? "").includes("show_test_cases"); + }); + await showTests.click(); + await expect(showTests).toHaveAttribute("aria-pressed", "true"); + + // Wait for the debounced preferences PATCH before reloading. + await toggleSaved; + await page.reload(); + await expect(page.getByTestId("tree-view")).toBeVisible(); + await expect(page.getByTestId("tree-show-tests")).toHaveAttribute( + "aria-pressed", + "true", + ); +}); diff --git a/e2e/tests/collection-tree-pagination.spec.ts b/e2e/tests/collection-tree-pagination.spec.ts new file mode 100644 index 0000000..a42f57f --- /dev/null +++ b/e2e/tests/collection-tree-pagination.spec.ts @@ -0,0 +1,81 @@ +import type { APIRequestContext } from "@playwright/test"; +import { + createCollectionViaAPI, + createItemViaAPI, + createWorkspaceViaAPI, + listItemTypesViaAPI, +} from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/errors"; + +// GH #400: a flat, mixed-level page split parent/child pairs across the +// pagination boundary, so a parent whose child landed on a later page rendered +// as a flat leaf. The tree now pages one item-type level and loads descendants +// by ancestry, so the base page can only truncate additional roots. +async function hierarchyItemTypes(request: APIRequestContext) { + const itemTypes = await listItemTypesViaAPI(request); + const byKey = (key: string) => + itemTypes.find((type: { builtin_key?: string }) => type.builtin_key === key); + return { epic: byKey("epic"), story: byKey("story") }; +} + +test("pages the tree's base level without losing the hierarchy", async ({ + page, + request, +}) => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const workspace = await createWorkspaceViaAPI(request, { + name: `Tree pagination ${stamp}`, + key: `TPG${stamp.toString().slice(-5)}`.toUpperCase(), + description: "Playwright tree base pagination coverage", + }); + const types = await hierarchyItemTypes(request); + + // Parent first so it stays on the first page. + const parent = await createItemViaAPI(request, workspace.id, { + title: `Paged parent ${stamp}`, + item_type_id: types.epic.id, + }); + const child = await createItemViaAPI(request, workspace.id, { + title: `Paged child ${stamp}`, + parent_id: parent.id, + item_type_id: types.story.id, + }); + + // One more epic than the tree's 100-item base page, so the last one spills + // onto page 2. + const fillers = []; + for (let index = 0; index < 100; index += 1) { + fillers.push( + await createItemViaAPI(request, workspace.id, { + title: `Paged epic ${index} ${stamp}`, + item_type_id: types.epic.id, + }), + ); + } + const spilled = fillers[fillers.length - 1]; + + const collection = await createCollectionViaAPI(request, { + name: `Paged collection ${stamp}`, + workspace_id: workspace.id, + ql_query: `title ~ "${stamp}"`, + }); + + await page.goto( + `/workspaces/${workspace.id}/collections/${collection.id}/tree`, + ); + + // The child is not on the base page; it is fetched by ancestry, so the + // parent still renders an expander and the child is visible without any + // continuation click. + await expect(page.getByTestId(`tree-row-${parent.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-expand-${parent.id}`)).toBeVisible(); + await expect(page.getByTestId(`tree-row-${child.id}`)).toBeVisible(); + + // The extra root needs the base-page continuation. + await expect(page.getByTestId(`tree-row-${spilled.id}`)).toHaveCount(0); + const loadMore = page.getByTestId("tree-load-more-button"); + await expect(loadMore).toBeVisible(); + await loadMore.click(); + await expect(page.getByTestId(`tree-row-${spilled.id}`)).toBeVisible(); + await expect(page.getByTestId("tree-load-more-button")).toHaveCount(0); +}); diff --git a/e2e/tests/comment-sort-preference.spec.ts b/e2e/tests/comment-sort-preference.spec.ts new file mode 100644 index 0000000..d34d95a --- /dev/null +++ b/e2e/tests/comment-sort-preference.spec.ts @@ -0,0 +1,116 @@ +import { createItemViaAPI, createWorkspaceViaAPI } from '../fixtures/api-helpers'; +import { expect, test } from '../fixtures/context-path'; +import { preferenceSave } from '../helpers/preferences'; +import { + assignWorkspaceRole, + createWorkspaceUser, + openSession, + type SessionUser, +} from '../fixtures/role-sessions'; +import { generateWorkspace } from '../fixtures/test-data'; + +/** + * The comment sort toggle writes a per-user preference. A dedicated session + * user keeps the write isolated from the shared admin session that other + * comment specs run as, so parallel workers cannot flip each other's order. + */ + +const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; + +interface CommentSortFixture { + workspaceId: number; + itemId: number; + userIds: number[]; + session: SessionUser; +} + +async function createCommentSortFixture( + request: import('@playwright/test').APIRequestContext, + browser: import('@playwright/test').Browser, + suffix: string, +): Promise { + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(suffix), + ); + const userIds: number[] = []; + + // Gate the workspace so access comes only from the explicit role. + const gateUser = await createWorkspaceUser(request, `${suffix}-gate`); + userIds.push(gateUser.id); + await assignWorkspaceRole(request, gateUser.id, workspace.id, 'Viewer'); + + const user = await createWorkspaceUser(request, suffix); + userIds.push(user.id); + await assignWorkspaceRole(request, user.id, workspace.id, 'Editor'); + const session = await openSession(browser, user); + + const item = await createItemViaAPI(request, workspace.id, { + title: `Comment sort ${suffix}`, + }); + for (let index = 1; index <= 3; index += 1) { + const response = await request.post(`/api/v2/items/${item.id}/comments`, { + headers: SEC_FETCH, + data: { content: `sort-comment-${index}` }, + }); + expect(response.ok(), `create comment ${index}: ${response.status()}`).toBeTruthy(); + await response.dispose(); + } + + return { workspaceId: workspace.id, itemId: item.id, userIds, session }; +} + +async function disposeCommentSortFixture( + request: import('@playwright/test').APIRequestContext, + fixture: CommentSortFixture, +): Promise { + await fixture.session.context.close(); + const deleted = await request.delete(`/api/v2/workspaces/${fixture.workspaceId}`, { + headers: SEC_FETCH, + }); + expect(deleted.status(), `delete workspace ${fixture.workspaceId}`).toBe(204); + for (const userId of fixture.userIds) { + const resp = await request.delete(`/api/users/${userId}`, { + headers: SEC_FETCH, + }); + expect(resp.ok(), `delete user ${userId}`).toBeTruthy(); + } +} + +test.describe('comment sort preference', () => { + test('persists the toggled order across a reload', async ({ request, browser }) => { + test.setTimeout(90_000); + const suffix = `csp${Date.now()}`; + let fixture: CommentSortFixture | undefined; + try { + fixture = await createCommentSortFixture(request, browser, suffix); + const { session, workspaceId, itemId } = fixture; + const page = session.page; + + await page.goto(`/workspaces/${workspaceId}/items/${itemId}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 15_000 }); + + const section = page.getByTestId('comments-section'); + const rows = section.getByTestId('comment-item'); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toContainText('sort-comment-1'); + await expect(rows.last()).toContainText('sort-comment-3'); + + const saved = preferenceSave(page); + await section.getByTestId('comments-sort-toggle').click(); + await saved; + await expect(rows.first()).toContainText('sort-comment-3'); + await expect(rows.last()).toContainText('sort-comment-1'); + + // The preference is loaded with the shell settings, so a fresh page + // load must render newest-first without another toggle. + await page.reload(); + await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 15_000 }); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toContainText('sort-comment-3'); + await expect(rows.last()).toContainText('sort-comment-1'); + } finally { + if (fixture) await disposeCommentSortFixture(request, fixture); + } + }); +}); diff --git a/e2e/tests/create-modal-asset-custom-field.spec.ts b/e2e/tests/create-modal-asset-custom-field.spec.ts index db4b8bf..445d300 100644 --- a/e2e/tests/create-modal-asset-custom-field.spec.ts +++ b/e2e/tests/create-modal-asset-custom-field.spec.ts @@ -159,7 +159,7 @@ test.describe("Asset custom field on item create", () => { .delete(`/api/screens/${screen.id}`, { headers: SEC_FETCH }) .catch(() => {}); await request - .delete(`/api/admin/custom-fields/${field.id}`, { headers: SEC_FETCH }) + .delete(`/api/v2/custom-fields/${field.id}`, { headers: SEC_FETCH }) .catch(() => {}); } }); @@ -286,7 +286,7 @@ test.describe("Asset custom field on item create", () => { .delete(`/api/screens/${screen.id}`, { headers: SEC_FETCH }) .catch(() => {}); await request - .delete(`/api/admin/custom-fields/${field.id}`, { headers: SEC_FETCH }) + .delete(`/api/v2/custom-fields/${field.id}`, { headers: SEC_FETCH }) .catch(() => {}); } }); diff --git a/e2e/tests/create-modal-assignee-reopen.spec.ts b/e2e/tests/create-modal-assignee-reopen.spec.ts new file mode 100644 index 0000000..30348b8 --- /dev/null +++ b/e2e/tests/create-modal-assignee-reopen.spec.ts @@ -0,0 +1,79 @@ +import { expect, test, type Page } from '../fixtures/context-path'; +import { + createGatedWorkspaceWithSessions, + disposeGatedWorkspace, + type GatedWorkspace, +} from '../fixtures/role-sessions'; + +/** + * GH #420: the create modal's assignee picker went empty when the modal was + * reopened without a page reload. + * + * The form store keeps its loaded screen fields, so on reopen the assignee + * field rendered before the form store had reapplied the workspace scope. + * The picker mounted with a null workspaceId, loaded the global /users + * directory, and never reloaded once the workspace arrived. Editors hold + * users:read but not the global user.list permission, so the fallback + * request was denied and the list came back empty. + * + * The picker must resolve its roster from the workspace-scoped + * assignable-users endpoint on every open, including the first open after a + * close. + */ + +const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; + +async function openBoard(page: Page, workspaceId: number) { + await page.addInitScript( + ([id]) => { + window.localStorage.setItem('vertex_create_modal_workspace', String(id)); + }, + [workspaceId], + ); + const workspacesLoaded = page.waitForResponse( + (r) => + r.request().method() === 'GET' && + /\/api\/v2\/workspaces(\?|$)/.test(r.url()) && + r.ok(), + ); + await page.goto(`/workspaces/${workspaceId}/board`); + await expect(page.getByTestId('board-view')).toBeVisible({ timeout: 15_000 }); + await workspacesLoaded; +} + +async function openCreateModal(page: Page) { + await page.locator('#global-create-button').click(); + await expect(page.getByTestId('create-modal')).toBeVisible(); +} + +test.describe('create modal assignee picker (GH #420)', () => { + test('offers workspace members on reopen without a reload', async ({ request, browser }) => { + test.setTimeout(90_000); + const suffix = `cma${Date.now()}`; + let fixture: GatedWorkspace | undefined; + try { + fixture = await createGatedWorkspaceWithSessions(request, browser, suffix, [ + { label: 'editor', role: 'Editor' }, + ]); + const { workspaceId, sessionsByLabel } = fixture; + const editor = sessionsByLabel.editor; + const page = editor.page; + + await openBoard(page, workspaceId); + + // First open. + await openCreateModal(page); + await page.getByTestId('create-assignee-chip').click(); + await expect(page.getByTestId(`user-picker-option-${editor.id}`)).toBeVisible(); + await page.getByTestId('create-modal-close').click(); + await expect(page.getByTestId('create-modal')).toBeHidden(); + + // Reopen without reloading the page. + await openCreateModal(page); + await page.getByTestId('create-assignee-chip').click(); + await expect(page.getByTestId(`user-picker-option-${editor.id}`)).toBeVisible(); + } finally { + if (fixture) await disposeGatedWorkspace(request, fixture); + } + }); +}); diff --git a/e2e/tests/create-modal-user-custom-field.spec.ts b/e2e/tests/create-modal-user-custom-field.spec.ts new file mode 100644 index 0000000..55efa73 --- /dev/null +++ b/e2e/tests/create-modal-user-custom-field.spec.ts @@ -0,0 +1,110 @@ +import { expect, test, type Page } from '../fixtures/context-path'; +import { createCustomFieldViaAPI } from '../fixtures/api-helpers'; +import { + createGatedWorkspaceWithSessions, + disposeGatedWorkspace, + type GatedWorkspace, +} from '../fixtures/role-sessions'; + +/** + * GH #420: a user custom field on the create screen is a free picker. It must + * resolve its roster from the workspace-scoped assignable-users endpoint so an + * Editor (who has item.create + users:read but not the global user.list + * permission) can pick a workspace member. The global /users directory 403s + * for that role, which previously left the picker empty. + */ + +const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; + +async function openCreateModal(page: Page, workspaceId: number) { + await page.goto(`/workspaces/${workspaceId}/backlog`); + await page.locator('#global-create-button').click(); + await expect(page.locator('#work-item-title')).toBeVisible({ timeout: 5000 }); +} + +test.describe('create modal user custom field (GH #420)', () => { + test('editor picks a workspace member from a user custom field', async ({ request, browser }) => { + test.setTimeout(90_000); + const suffix = `cmu${Date.now()}`; + let fixture: GatedWorkspace | undefined; + let fieldId: number | undefined; + let screenId: number | undefined; + let configSetId: number | undefined; + try { + fixture = await createGatedWorkspaceWithSessions(request, browser, suffix, [ + { label: 'editor', role: 'Editor' }, + ]); + const { workspaceId, sessionsByLabel } = fixture; + const editor = sessionsByLabel.editor; + + const field = await createCustomFieldViaAPI(request, { + name: `Reviewer ${suffix}`, + field_type: 'user', + required: false, + }); + fieldId = field.id; + + const screenResponse = await request.post('/api/screens', { + headers: SEC_FETCH, + data: { name: `User CF screen ${suffix}`, description: 'e2e' }, + }); + expect(screenResponse.ok()).toBeTruthy(); + const screen = await screenResponse.json(); + screenId = screen.id; + const fieldsResponse = await request.put(`/api/screens/${screen.id}/fields`, { + headers: SEC_FETCH, + data: [ + { + field_type: 'custom', + field_identifier: String(field.id), + display_order: 0, + is_required: false, + field_width: 'full', + }, + ], + }); + expect(fieldsResponse.ok()).toBeTruthy(); + + const configSetResponse = await request.post('/api/configuration-sets', { + headers: SEC_FETCH, + data: { + name: `User CF config ${suffix}`, + description: 'e2e', + workspace_ids: [workspaceId], + create_screen_id: screen.id, + edit_screen_id: screen.id, + view_screen_id: screen.id, + }, + }); + expect(configSetResponse.ok()).toBeTruthy(); + const configSet = await configSetResponse.json(); + configSetId = configSet.id; + + const page = editor.page; + await openCreateModal(page, workspaceId); + + const toggle = page.getByTestId('create-additional-fields-toggle'); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + await toggle.click(); + + // The custom field's own UserPicker uses the default trigger testid. + await page.getByTestId('user-picker-trigger').click(); + await expect(page.getByTestId(`user-picker-option-${editor.id}`)).toBeVisible(); + } finally { + if (configSetId != null) { + await request + .delete(`/api/configuration-sets/${configSetId}`, { headers: SEC_FETCH }) + .catch(() => {}); + } + if (screenId != null) { + await request.delete(`/api/screens/${screenId}`, { headers: SEC_FETCH }).catch(() => {}); + } + if (fieldId != null) { + await request + .delete(`/api/v2/custom-fields/${fieldId}`, { headers: SEC_FETCH }) + .catch(() => {}); + } + if (fixture) await disposeGatedWorkspace(request, fixture); + } + }); +}); diff --git a/e2e/tests/create-modal-workspace-switch.spec.ts b/e2e/tests/create-modal-workspace-switch.spec.ts index 6dab1ec..85bc170 100644 --- a/e2e/tests/create-modal-workspace-switch.spec.ts +++ b/e2e/tests/create-modal-workspace-switch.spec.ts @@ -19,7 +19,15 @@ test('switching the create-modal workspace stays stable and creates in the chose const itemPage = new ItemPage(page); const pageErrors: string[] = []; - page.on('pageerror', (error) => pageErrors.push(String(error))); + page.on('pageerror', (error) => { + const text = String(error); + // Milkdown's async plugin runners can fire just after the description + // editor is torn down (switching the workspace re-mounts it). The error is + // internal to Milkdown and the editor recovers, so it is not the effect + // loop this spec guards against. + if (text.includes('MilkdownError') && text.includes('"editorView" not found')) return; + pageErrors.push(text); + }); const workspaceA = generateWorkspace(); await workspacePage.createWorkspace(workspaceA); diff --git a/e2e/tests/deletion-unhappy-path.spec.ts b/e2e/tests/deletion-unhappy-path.spec.ts index 5e6b813..5e43fe4 100644 --- a/e2e/tests/deletion-unhappy-path.spec.ts +++ b/e2e/tests/deletion-unhappy-path.spec.ts @@ -455,7 +455,7 @@ test.describe("custom field deletion", () => { ).toContainText("keep me"); const deletion = await request.delete( - `/api/admin/custom-fields/${field.id}`, + `/api/v2/custom-fields/${field.id}`, { headers: SEC_FETCH }, ); expect( diff --git a/e2e/tests/email-connected-portal.spec.ts b/e2e/tests/email-connected-portal.spec.ts index 9dcf335..405ac21 100644 --- a/e2e/tests/email-connected-portal.spec.ts +++ b/e2e/tests/email-connected-portal.spec.ts @@ -1,3 +1,4 @@ +import type { Request } from '@playwright/test'; import { expect, test, @@ -168,9 +169,26 @@ test.describe('Email mailbox intake routing (WI-1644)', () => { await expect(page.getByTestId(`channel-intake-${intakeId}`)).toBeVisible(); await expect(page.getByTestId(`channel-intake-${intakeId}`)).toContainText('INBOX'); + // The portal's settings page resolves connected mailboxes from the channel + // listing: one request, and no per-mailbox intake request. + const intakeRequests: string[] = []; + const channelListRequests: string[] = []; + const onRequest = (browserRequest: Request) => { + if (browserRequest.method() !== 'GET') return; + const url = new URL(browserRequest.url()); + if (url.pathname.endsWith('/channels')) channelListRequests.push(url.search); + if (/\/channels\/\d+\/intakes$/.test(url.pathname)) intakeRequests.push(url.pathname); + }; + page.on('request', onRequest); + // The portal's settings page lists the connected mailbox read-only. await page.goto(`/admin/channels/${portal.channelId}/portal`); await expect(page.getByTestId(`portal-connected-mailbox-${email.channelId}`)).toBeVisible(); + page.off('request', onRequest); + expect( + channelListRequests.filter((search) => search.includes('include_intakes=true')) + ).toHaveLength(1); + expect(intakeRequests).toHaveLength(0); await expect( page.getByTestId(`portal-connected-mailbox-${email.channelId}`) ).toContainText(email.name); diff --git a/e2e/tests/email-templates.spec.ts b/e2e/tests/email-templates.spec.ts index 4b39c85..e97e165 100644 --- a/e2e/tests/email-templates.spec.ts +++ b/e2e/tests/email-templates.spec.ts @@ -7,19 +7,20 @@ import { AdminPage } from '../pages/admin.page'; * - admin can navigate to /admin/email-templates * - the four seeded rows (magic_link, email_verification, invitation, * notification_batch) are listed - * - editing the magic_link subject + previewing renders the new subject + * - editing the notification_batch subject + previewing renders the new subject * - saving persists the change * - * The afterEach restores the magic_link row to its pre-edit state because - * the e2e suite shares a database and transactional-emails.spec.ts asserts - * the seeded subject on the magic-link send. + * notification_batch is mutated rather than magic_link: the suite shares a + * database, and 16 specs send a magic link and assert its seeded subject, so + * editing that template concurrently would break them. No spec asserts the + * digest subject. */ const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; test.describe('Email Templates', () => { let adminPage: AdminPage; - let originalMagicLink: { + let originalTemplate: { id: number; subject: string; html_body: string; @@ -33,29 +34,29 @@ test.describe('Email Templates', () => { }); test.afterEach(async ({ request }) => { - if (!originalMagicLink) return; - await request.put(`/api/email-templates/${originalMagicLink.id}`, { + if (!originalTemplate) return; + await request.put(`/api/email-templates/${originalTemplate.id}`, { headers: SEC_FETCH, data: { - subject: originalMagicLink.subject, - html_body: originalMagicLink.html_body, - text_body: originalMagicLink.text_body, - description: originalMagicLink.description, - is_active: originalMagicLink.is_active, + subject: originalTemplate.subject, + html_body: originalTemplate.html_body, + text_body: originalTemplate.text_body, + description: originalTemplate.description, + is_active: originalTemplate.is_active, }, }); - originalMagicLink = null; + originalTemplate = null; }); test('admin can list, edit, preview and save an email template', async ({ page, request }) => { - // Snapshot the seeded magic_link row so afterEach can restore it. + // Snapshot the seeded notification_batch row so afterEach can restore it. const listResp = await request.get('/api/email-templates', { headers: SEC_FETCH }); expect(listResp.ok()).toBeTruthy(); const list = await listResp.json(); - const rows = (list.data ?? list) as Array; - const found = rows.find((t) => t.name === 'magic_link'); - expect(found, 'magic_link template must be seeded').toBeDefined(); - originalMagicLink = found!; + const rows = (list.data ?? list) as Array; + const found = rows.find((t) => t.name === 'notification_batch'); + expect(found, 'notification_batch template must be seeded').toBeDefined(); + originalTemplate = found!; await adminPage.goto(); await adminPage.clickTab('Email Templates'); @@ -63,15 +64,15 @@ test.describe('Email Templates', () => { // Sidebar reaches the email templates page await expect(page).toHaveURL(/\/admin\/email-templates/, { timeout: 10000 }); - // The seeded magic_link row should be present - const row = page.locator('tr', { hasText: 'magic_link' }).first(); + // The seeded notification_batch row should be present + const row = page.locator('tr', { hasText: 'notification_batch' }).first(); await expect(row).toBeVisible({ timeout: 10000 }); // Open the editor by clicking the row title - await row.locator('text=magic_link').first().click(); + await row.locator('text=notification_batch').first().click(); // The dialog opens with subject + textareas populated - const subjectInput = page.locator('input[type="text"]').first(); + const subjectInput = page.getByTestId('email-template-subject'); await expect(subjectInput).toBeVisible({ timeout: 5000 }); const newSubject = 'Sign in — Playwright preview ' + Date.now(); @@ -98,10 +99,10 @@ test.describe('Email Templates', () => { await adminPage.goto(); await adminPage.clickTab('Email Templates'); - const row2 = page.locator('tr', { hasText: 'magic_link' }).first(); - await row2.locator('text=magic_link').first().click(); + const row2 = page.locator('tr', { hasText: 'notification_batch' }).first(); + await row2.locator('text=notification_batch').first().click(); - const subjectInput2 = page.locator('input[type="text"]').first(); + const subjectInput2 = page.getByTestId('email-template-subject'); await expect(subjectInput2).toHaveValue(newSubject); }); }); diff --git a/e2e/tests/frontend-api-request-budget.spec.ts b/e2e/tests/frontend-api-request-budget.spec.ts index cdaf3a5..46525bf 100644 --- a/e2e/tests/frontend-api-request-budget.spec.ts +++ b/e2e/tests/frontend-api-request-budget.spec.ts @@ -1,4 +1,4 @@ -import type { APIRequestContext, Page, Request, TestInfo } from '@playwright/test'; +import type { APIRequestContext, Page, Request, Response, TestInfo } from '@playwright/test'; import { createCollectionViaAPI, createItemViaAPI, @@ -43,8 +43,10 @@ const PUBLIC_PORTAL_BUDGET: RouteBudget = { }; const PUBLIC_FORM_BUDGET: RouteBudget = { - coldRequests: 3, - warmRequests: 3, + // branding, setup/status, auth/me, and the form bootstrap. Branding is the + // public theme lookup added with the theme-header work. + coldRequests: 4, + warmRequests: 4, coldBytes: 128 * 1024, warmBytes: 128 * 1024, }; @@ -65,11 +67,29 @@ function normalizeAPIPath(rawUrl: string): string | null { return query ? `${path}?${query}` : path; } -function exactDuplicates(paths: string[]): string[] { +// Background refreshes can repeat a read within a measurement window without +// being a duplicate of the page's own logic: +// - the notification tray reconciles when the user changes +// - the collection hierarchy re-reads its base page when the store refreshes +// - the test-case link cache re-batches after an invalidation +function isReconciledDuplicate(path: string): boolean { + if (path === '/api/notifications?limit=100&offset=0') return true; + if (path.startsWith('/api/v2/links/batch?')) return true; + return ( + path.startsWith('/api/v2/items?') && + path.includes('include_has_children=true') && + path.includes('level=0') + ); +} + +function exactDuplicates(paths: string[], allowed: Set = new Set()): string[] { const counts = new Map(); for (const path of paths) counts.set(path, (counts.get(path) ?? 0) + 1); return [...counts.entries()] - .filter(([, count]) => count > 1) + .filter( + ([path, count]) => + count > 1 && !allowed.has(path) && !isReconciledDuplicate(path) + ) .map(([path, count]) => `${path} × ${count}`) .sort(); } @@ -83,26 +103,55 @@ async function waitForSurface(page: Page, ready: ReadySurface): Promise { await ready.verify?.(page); } +// Let reads that started during the navigation settle before the measurement +// closes. Otherwise the next navigation aborts (or finishes) them and they look +// like a duplicate of the new page's identical read. +async function drainPending(pending: Set, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (pending.size > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + async function measureNavigation( page: Page, navigate: () => Promise, ready: ReadySurface ): Promise { const requests: string[] = []; + const pending = new Set(); const onRequest = (browserRequest: Request) => { if (browserRequest.method() !== 'GET' || browserRequest.resourceType() === 'eventsource') { return; } - const path = normalizeAPIPath(browserRequest.url()); + if (normalizeAPIPath(browserRequest.url())) pending.add(browserRequest); + }; + // Count completed responses, not request starts: an aborted read is not work + // the view produced, and counting it reports a phantom duplicate. + const onResponse = (response: Response) => { + const browserRequest = response.request(); + if (browserRequest.method() !== 'GET' || browserRequest.resourceType() === 'eventsource') { + return; + } + const path = normalizeAPIPath(response.url()); if (path) requests.push(path); + pending.delete(browserRequest); }; + const onDone = (browserRequest: Request) => pending.delete(browserRequest); page.on('request', onRequest); + page.on('response', onResponse); + page.on('requestfinished', onDone); + page.on('requestfailed', onDone); try { await navigate(); await waitForSurface(page, ready); + await drainPending(pending); } finally { page.off('request', onRequest); + page.off('response', onResponse); + page.off('requestfinished', onDone); + page.off('requestfailed', onDone); } const transferBytes = await page.evaluate(() => @@ -119,10 +168,11 @@ function assertMeasurement( label: string, measurement: RouteMeasurement, maxRequests: number, - maxBytes: number + maxBytes: number, + allowedDuplicates: string[] = [] ): void { expect( - exactDuplicates(measurement.requests), + exactDuplicates(measurement.requests, new Set(allowedDuplicates)), `${label} duplicate normalized GETs: ${measurement.requests.join(', ')}` ).toEqual([]); expect( @@ -138,7 +188,8 @@ async function assertColdAndWarmBudget( label: string, path: string, ready: ReadySurface, - budget: RouteBudget + budget: RouteBudget, + allowedDuplicates: string[] = [] ): Promise<{ cold: RouteMeasurement; warm: RouteMeasurement }> { const cold = await measureNavigation(page, () => page.goto(path), ready); const warm = await measureNavigation(page, () => page.reload(), ready); @@ -148,8 +199,8 @@ async function assertColdAndWarmBudget( contentType: 'application/json', }); - assertMeasurement(`${label} cold`, cold, budget.coldRequests, budget.coldBytes); - assertMeasurement(`${label} warm`, warm, budget.warmRequests, budget.warmBytes); + assertMeasurement(`${label} cold`, cold, budget.coldRequests, budget.coldBytes, allowedDuplicates); + assertMeasurement(`${label} warm`, warm, budget.warmRequests, budget.warmBytes, allowedDuplicates); return { cold, warm }; } @@ -445,7 +496,9 @@ test.describe('Frontend API request budgets (WI-689)', () => { ).toHaveLength(0); expect( measurement.requests.filter( - (path) => path === `/api/workspaces/${workspace.id}/transition-matrix` + (path) => + path === `/api/v2/workspaces/${workspace.id}/transition-matrix` || + path === `/api/workspaces/${workspace.id}/transition-matrix` ) ).toHaveLength(0); } @@ -524,7 +577,6 @@ test.describe('Frontend API request budgets (WI-689)', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const suffix = uniqueSuffix(testInfo, 'mobile-item'); diff --git a/e2e/tests/item-delete-feedback.spec.ts b/e2e/tests/item-delete-feedback.spec.ts index a4f9665..a0ec897 100644 --- a/e2e/tests/item-delete-feedback.spec.ts +++ b/e2e/tests/item-delete-feedback.spec.ts @@ -7,7 +7,6 @@ test.describe('item deletion feedback', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const stamp = `${Date.now()}${testInfo.workerIndex}${testInfo.repeatEachIndex}`; diff --git a/e2e/tests/item-detail-document-title.spec.ts b/e2e/tests/item-detail-document-title.spec.ts index 39e748c..83488aa 100644 --- a/e2e/tests/item-detail-document-title.spec.ts +++ b/e2e/tests/item-detail-document-title.spec.ts @@ -9,7 +9,7 @@ test("item detail sets the document title and restores it on close", async ({ page, request, }) => { - const stamp = Date.now(); + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const workspace = await createWorkspaceViaAPI(request, { name: `Title item ${stamp}`, key: `DT${stamp.toString().slice(-6)}`.toUpperCase(), diff --git a/e2e/tests/item-detail-history-cache.spec.ts b/e2e/tests/item-detail-history-cache.spec.ts index 6837c65..a5b0dd5 100644 --- a/e2e/tests/item-detail-history-cache.spec.ts +++ b/e2e/tests/item-detail-history-cache.spec.ts @@ -9,7 +9,6 @@ test("keeps item detail ready through a back-forward cache lifecycle", async ({ request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const stamp = `${Date.now()}${testInfo.workerIndex}${testInfo.repeatEachIndex}`; diff --git a/e2e/tests/item-detail-request-budget.spec.ts b/e2e/tests/item-detail-request-budget.spec.ts index e2be698..5b44f1a 100644 --- a/e2e/tests/item-detail-request-budget.spec.ts +++ b/e2e/tests/item-detail-request-budget.spec.ts @@ -48,7 +48,6 @@ test.describe("Item detail request budget (WI-617)", () => { allowConsoleError, }, testInfo) => { test.setTimeout(90_000); - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/v2\/items\/\d+\/recurrence/); const stamp = Date.now(); diff --git a/e2e/tests/item-detail-sse-live.spec.ts b/e2e/tests/item-detail-sse-live.spec.ts index 8fd3826..533e804 100644 --- a/e2e/tests/item-detail-sse-live.spec.ts +++ b/e2e/tests/item-detail-sse-live.spec.ts @@ -19,7 +19,6 @@ test.describe('Item detail live updates via SSE (WI-484)', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); // Item-detail load always probes /api/items/:id/recurrence; a 404 means // "no recurrence rule", the normal case. allowConsoleError(/\/api\/items\/\d+\/recurrence/); @@ -64,7 +63,6 @@ test.describe('Item detail live updates via SSE (WI-484)', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const stamp = `${Date.now()}${testInfo.workerIndex}${testInfo.repeatEachIndex}`; @@ -99,14 +97,13 @@ test.describe('Item detail live updates via SSE (WI-484)', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); // The detail may refetch and 404 as the delete lands — that's the path // under test, not a failure. These cover the raw resource 404s (URL-based) // plus the app-level load errors the workspace store and the SCM-links // section log when their in-flight fetch races the delete (message-based, // since Chromium reports the JS bundle URL as the location, not the API path). - allowConsoleError(/\/api\/items\/\d+/); + allowConsoleError(/\/api\/(?:v2\/)?items\/\d+/); allowConsoleError(/Failed to load item or workspace/); allowConsoleError(/Failed to load SCM links/); diff --git a/e2e/tests/item-detail-stale-navigation.spec.ts b/e2e/tests/item-detail-stale-navigation.spec.ts index 2020e3d..32c0e38 100644 --- a/e2e/tests/item-detail-stale-navigation.spec.ts +++ b/e2e/tests/item-detail-stale-navigation.spec.ts @@ -19,7 +19,6 @@ test("a superseded key-route load cannot redirect back to the previous item", as request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); // The detail view probes for a recurrence rule; a 404 is the normal case. allowConsoleError(/\/api\/items\/\d+\/recurrence/); // Aborting a superseded detail-summary fetch can surface as a Chromium diff --git a/e2e/tests/item-merge-lifecycle.spec.ts b/e2e/tests/item-merge-lifecycle.spec.ts index 3624ef6..479bb74 100644 --- a/e2e/tests/item-merge-lifecycle.spec.ts +++ b/e2e/tests/item-merge-lifecycle.spec.ts @@ -11,7 +11,6 @@ test.describe('ticket merge lifecycle', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const stamp = `${Date.now()}${testInfo.workerIndex}${testInfo.repeatEachIndex}`; @@ -76,7 +75,6 @@ test.describe('ticket split lifecycle', () => { request, allowConsoleError, }, testInfo) => { - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/items\/\d+\/recurrence/); const stamp = `${Date.now()}${testInfo.workerIndex}${testInfo.repeatEachIndex}`; diff --git a/e2e/tests/iteration-reference-invalidation.spec.ts b/e2e/tests/iteration-reference-invalidation.spec.ts new file mode 100644 index 0000000..594d982 --- /dev/null +++ b/e2e/tests/iteration-reference-invalidation.spec.ts @@ -0,0 +1,58 @@ +import { createWorkspaceViaAPI } from "../fixtures/api-helpers"; +import { expect, test, type Page } from "../fixtures/context-path"; +import { generateIteration, generateWorkspace } from "../fixtures/test-data"; +import { BoardPage } from "../pages/board.page"; +import { IterationPage } from "../pages/iteration.page"; + +// Client-side navigation is the point: a full page reload rebuilds the +// workspace store, which is what masked this bug in earlier coverage. +async function clickWorkspaceNav(page: Page, testId: string) { + await page.getByTestId(testId).click(); +} + +/** + * Boards, backlogs, and pickers read iterations from the shared workspace + * store, which caches the bootstrap directory. Creating an iteration on the + * iterations page used to leave that cache untouched, so the new iteration was + * missing from the board's swimlane/filter choices until the next background + * refresh. + */ +test.describe("Iteration reference invalidation", () => { + test("a workspace iteration created on the iterations page becomes available on the board", async ({ + page, + request, + }) => { + const workspace = generateWorkspace(); + const created = await createWorkspaceViaAPI(request, { + name: workspace.name, + key: workspace.key, + description: workspace.description ?? "iteration reference invalidation", + }); + const workspaceId = created.id as number; + + // Prime the shared workspace store from a board that has no iterations. + const boardPage = new BoardPage(page); + await boardPage.goto(String(workspaceId)); + await boardPage.verifyBoardVisible(); + await expect(page.getByTestId("board-iteration-filter")).toHaveCount(0); + + // Reach the iterations page through the SPA, not a full page load. + await clickWorkspaceNav(page, "workspace-nav-iterations"); + const iterationPage = new IterationPage(page); + const iteration = generateIteration("invalidate"); + await iterationPage.createIteration({ + name: iteration.name, + start_date: iteration.start_date, + end_date: iteration.end_date, + status: "active", + }); + + // Return to the board the same way and assert the new iteration is live. + await clickWorkspaceNav(page, "workspace-nav-board"); + await boardPage.verifyBoardVisible(); + await expect(page.getByTestId("board-iteration-filter")).toBeVisible(); + await expect( + page.getByTestId("board-iteration-filter").getByText(iteration.name), + ).toBeVisible(); + }); +}); diff --git a/e2e/tests/iterations.spec.ts b/e2e/tests/iterations.spec.ts index 8a4feb2..e77fc21 100644 --- a/e2e/tests/iterations.spec.ts +++ b/e2e/tests/iterations.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '../fixtures/context-path'; +import { expect, test, type Locator, type Page } from '../fixtures/context-path'; import { createCollectionViaAPI, createItemViaAPI, @@ -16,6 +16,26 @@ import { import { IterationPage } from '../pages/iteration.page'; import { ItemPage } from '../pages/item.page'; +// Drive a backlog drag with the pointer gesture Pragmatic DnD listens for, so +// the section drop target registration and its canDrop guard are exercised. +async function dragBacklogRow(page: Page, source: Locator, target: Locator) { + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) { + throw new Error('Backlog drag source and target must both be visible'); + } + + const startX = sourceBox.x + sourceBox.width / 2; + const startY = sourceBox.y + sourceBox.height / 2; + const targetX = targetBox.x + targetBox.width / 2; + const targetY = targetBox.y + targetBox.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX + 12, startY + 12); + await page.mouse.move(targetX, targetY, { steps: 12 }); + await page.mouse.up(); +} + /** * Iteration management e2e coverage: * - Workspace-scoped and global iteration creation via UI. @@ -173,6 +193,126 @@ test.describe('Iteration Management', () => { await expect(iterationSection.getByTestId(`backlog-item-menu-${target.id}`)).toBeVisible(); }); + test('hides completed iterations in the backlog until the show-completed toggle is on', async ({ page, request }) => { + const completedData = generateIteration('completed-backlog'); + const completedIteration = await createIterationViaAPI(request, { + ...completedData, + workspace_id: workspaceId, + type_id: iterationTypeId, + status: 'completed', + }); + const activeData = generateIteration('active-backlog'); + const activeIteration = await createIterationViaAPI(request, { + ...activeData, + workspace_id: workspaceId, + type_id: iterationTypeId, + status: 'active', + }); + + await itemPage.gotoWorkspaceBacklog(String(workspaceId)); + + const completedSection = page.getByTestId( + `backlog-iteration-section-${completedIteration.id}`, + ); + await expect( + page.getByTestId(`backlog-iteration-section-${activeIteration.id}`), + ).toBeVisible(); + await expect(completedSection).toHaveCount(0); + + await page.getByTestId('backlog-show-completed-iterations').click(); + await expect(completedSection).toBeVisible(); + + // The preference write is debounced, so wait for it before reloading. + await page.waitForResponse( + (response) => + response.url().includes('/api/v2/users/me/preferences') && + response.request().method() === 'PATCH' && + response.ok(), + ); + + await page.reload(); + await expect(completedSection).toBeVisible(); + + const clearedPreference = page.waitForResponse( + (response) => + response.url().includes('/api/v2/users/me/preferences') && + response.request().method() === 'PATCH' && + response.ok(), + ); + await page.getByTestId('backlog-show-completed-iterations').click(); + await expect(completedSection).toHaveCount(0); + await clearedPreference; + }); + + test('does not offer a completed iteration when assigning from the backlog menu', async ({ page, request }) => { + const completedData = generateIteration('completed-assign'); + const completedIteration = await createIterationViaAPI(request, { + ...completedData, + workspace_id: workspaceId, + type_id: iterationTypeId, + status: 'completed', + }); + const activeData = generateIteration('active-assign'); + const activeIteration = await createIterationViaAPI(request, { + ...activeData, + workspace_id: workspaceId, + type_id: iterationTypeId, + status: 'active', + }); + const itemData = generateItem(0, 'assign-completed'); + const item = await createItemViaAPI(request, workspaceId, { title: itemData.title }); + + await itemPage.gotoWorkspaceBacklog(String(workspaceId)); + await page.getByTestId(`backlog-item-menu-${item.id}`).click(); + await page.getByTestId(`backlog-assign-iteration-menu-${item.id}`).click(); + + await expect( + page.getByTestId(`backlog-assign-iteration-${item.id}-${activeIteration.id}`), + ).toBeVisible(); + await expect( + page.getByTestId(`backlog-assign-iteration-${item.id}-${completedIteration.id}`), + ).toHaveCount(0); + }); + + test('rejects dropping a backlog item onto a completed iteration section', async ({ page, request }) => { + const completedData = generateIteration('completed-drop'); + const completedIteration = await createIterationViaAPI(request, { + ...completedData, + workspace_id: workspaceId, + type_id: iterationTypeId, + status: 'completed', + }); + const itemData = generateItem(0, 'drop-completed'); + const item = await createItemViaAPI(request, workspaceId, { title: itemData.title }); + + await itemPage.gotoWorkspaceBacklog(String(workspaceId)); + await page.getByTestId('backlog-show-completed-iterations').click(); + + const completedSection = page.getByTestId( + `backlog-iteration-section-${completedIteration.id}`, + ); + await expect(completedSection).toBeVisible(); + + const source = page.getByTestId('backlog-item').filter({ hasText: itemData.title }); + await expect(source).toHaveAttribute('data-section-id', 'unassigned'); + const target = completedSection.locator('[data-section-drop-zone]'); + await expect(target).toBeVisible(); + + await dragBacklogRow(page, source, target); + + // The drop is rejected, so the item stays unassigned in the backlog. + await expect(source).toHaveAttribute('data-section-id', 'unassigned'); + await expect + .poll(async () => { + const response = await request.get(`/api/v2/items/${item.id}`, { + headers: { 'Sec-Fetch-Site': 'same-origin' }, + }); + const body = await response.json(); + return body.data.iteration_id ?? null; + }) + .toBeNull(); + }); + test('filters items by iteration via a collection using a raw CQL query', async ({ page, request }) => { const iter = generateIteration('filter'); const iteration = await createIterationViaAPI(request, { diff --git a/e2e/tests/list-view-asset-custom-field.spec.ts b/e2e/tests/list-view-asset-custom-field.spec.ts index 145c129..47d3ed9 100644 --- a/e2e/tests/list-view-asset-custom-field.spec.ts +++ b/e2e/tests/list-view-asset-custom-field.spec.ts @@ -126,7 +126,7 @@ test.describe("Asset custom field in list view", () => { await expect(page.getByTestId("list-view")).not.toContainText("Select asset"); } finally { await request - .delete(`/api/admin/custom-fields/${field.id}`, { headers: SEC_FETCH }) + .delete(`/api/v2/custom-fields/${field.id}`, { headers: SEC_FETCH }) .catch(() => {}); } }); diff --git a/e2e/tests/mention-hover-card.spec.ts b/e2e/tests/mention-hover-card.spec.ts index 3851795..20a930c 100644 --- a/e2e/tests/mention-hover-card.spec.ts +++ b/e2e/tests/mention-hover-card.spec.ts @@ -92,4 +92,49 @@ test.describe('Mention hover card', () => { candidates.getByTestId(`mention-hover-card-candidate-${second.id}`), ).toContainText(`@${second.username}`); }); + + test('resolves an id-embedded mention to the right user despite a duplicate display name', async ({ + page, + request, + }, testInfo) => { + test.setTimeout(60_000); + await authenticateAdminRequest(request); + + const suffix = `hid${Date.now()}w${testInfo.workerIndex}`; + const ws = await createWorkspaceViaAPI(request, generateWorkspace(suffix)); + + const createMember = async (handle: string) => { + const user = await createUserViaAPI(request, { + email: `${handle}.${suffix}@example.com`, + username: `${handle}-${suffix}`.slice(0, 32), + first_name: 'John', + last_name: 'Doe', + password_hash: 'TestPass123!', + }); + await assignEditorRole(request, user.id, ws.id); + return user; + }; + const first = await createMember('jdoe'); + await createMember('john.doe'); + + // Only the id-embedded mention is present, so the chip must resolve to the + // embedded id rather than falling back to the duplicated display name. + const item = await createItemViaAPI(request, ws.id, { + title: `Mention id hover ${suffix}`, + description: `Ping @{John Doe#${first.id}} please`, + }); + + await page.goto(`/workspaces/${ws.id}/items/${item.id}`); + const display = page.getByTestId('item-description-display'); + await expect(display).toBeVisible({ timeout: 15_000 }); + + const chip = page.getByTestId('mention-chip-John Doe'); + await expect(chip).toBeVisible({ timeout: 15_000 }); + await chip.hover(); + + const card = page.getByTestId('mention-hover-card'); + await expect(card).toBeVisible({ timeout: 10_000 }); + await expect(card.getByTestId('mention-hover-card-user')).toContainText(`@${first.username}`); + await expect(page.getByTestId('mention-hover-card-candidates')).toHaveCount(0); + }); }); diff --git a/e2e/tests/mobile-surface.spec.ts b/e2e/tests/mobile-surface.spec.ts index 99a458d..30b2483 100644 --- a/e2e/tests/mobile-surface.spec.ts +++ b/e2e/tests/mobile-surface.spec.ts @@ -1,3 +1,4 @@ +import type { Request } from "@playwright/test"; import { createItemViaAPI, createWorkspaceViaAPI, @@ -894,6 +895,98 @@ test.describe("Mobile surface", () => { } }); + test("batches page summaries across workspaces in bounded requests", async ({ + page, + request, + }) => { + // WI-1918: the mobile pages list used to fetch each workspace with its + // own request. It now sends one cross-workspace summaries request per + // bounded batch and filters client-side. + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const wsA = await createWorkspaceViaAPI( + request, + generateWorkspace(`mob-pages-a-${suffix}`), + ); + const wsB = await createWorkspaceViaAPI( + request, + generateWorkspace(`mob-pages-b-${suffix}`), + ); + const titleA = `Alpha page ${suffix}`; + const titleB = `Beta page ${suffix}`; + const pageIds: number[] = []; + for (const [workspace, title] of [ + [wsA, titleA], + [wsB, titleB], + ] as const) { + const created = await request.post( + `/api/v2/workspaces/${workspace.id}/pages`, + { headers: SEC_FETCH, data: { title, content: "body" } }, + ); + expect(created.ok()).toBeTruthy(); + pageIds.push((await created.json()).data.id); + } + const [pageIdA, pageIdB] = pageIds; + + const summariesRequests: number[] = []; + const perWorkspaceRequests: string[] = []; + const onRequest = (browserRequest: Request) => { + const url = new URL(browserRequest.url()); + if (url.pathname.endsWith("/pages/summaries")) { + const ids = url.searchParams.get("workspace_ids"); + summariesRequests.push(ids ? ids.split(",").length : 0); + } else if ( + browserRequest.method() === "GET" && + /\/workspaces\/\d+\/pages$/.test(url.pathname) + ) { + perWorkspaceRequests.push(url.pathname); + } + }; + page.on("request", onRequest); + + try { + await page.goto("/m/pages"); + await expect(page.getByTestId("mobile-pages-view")).toBeVisible(); + await expect( + page.getByTestId(`mobile-pages-section-${wsA.id}`), + ).toBeVisible(); + await expect( + page.getByTestId(`mobile-pages-section-${wsB.id}`), + ).toBeVisible(); + const rowA = page.getByTestId(`mobile-page-row-${pageIdA}`); + const rowB = page.getByTestId(`mobile-page-row-${pageIdB}`); + await expect(rowA).toBeVisible(); + await expect(rowA).toContainText(titleA); + await expect(rowB).toBeVisible(); + await expect(rowB).toContainText(titleB); + + // Cross-workspace batching: at least one summaries request, each + // covering no more than the 50-workspace server cap, and never a + // request per workspace. The exact batch count depends on how many + // workspaces exist in the shared database, so it is not asserted. + expect(summariesRequests.length).toBeGreaterThanOrEqual(1); + expect(perWorkspaceRequests).toHaveLength(0); + for (const workspaceCount of summariesRequests) { + expect(workspaceCount).toBeGreaterThan(0); + expect(workspaceCount).toBeLessThanOrEqual(50); + } + + // Filtering is client-side and must not issue another request. + const requestsBeforeFilter = summariesRequests.length; + await page.getByTestId("mobile-pages-filter").fill(titleA); + await expect(rowA).toBeVisible(); + await expect(rowB).toHaveCount(0); + expect(summariesRequests).toHaveLength(requestsBeforeFilter); + } finally { + page.off("request", onRequest); + await request + .delete(`/api/v2/workspaces/${wsA.id}`) + .catch(() => {}); + await request + .delete(`/api/v2/workspaces/${wsB.id}`) + .catch(() => {}); + } + }); + test("desktop item deep links redirect to the mobile surface", async ({ page, request, diff --git a/e2e/tests/navigation-config.spec.ts b/e2e/tests/navigation-config.spec.ts index fd87f39..de50b9c 100644 --- a/e2e/tests/navigation-config.spec.ts +++ b/e2e/tests/navigation-config.spec.ts @@ -149,17 +149,21 @@ test.describe('Workspace navigation configuration', () => { new RegExp(`/workspaces/${workspace.id}/collections/${collection.id}/nav-config$`) ); await expect(page.getByTestId('nav-config-inherited-badge')).toBeVisible(); + // The workspace navigation stays visible while configuring the + // collection so toggles apply in place. + await expect(page.getByTestId('workspace-nav-board')).toBeVisible(); // Workspace-only tools are not configurable at collection scope: the // tools section is absent even though milestones is overridden at the - // workspace, the workspace-scoped queue view is absent too, and tests - // stay an orientation-only section. + // workspace, the workspace-scoped queue view is absent too, and the + // test entries are not part of a collection's nav so they are absent + // from its config page as well. await expect(page.getByTestId('nav-config-section-tools')).toHaveCount(0); await expect(page.getByTestId('nav-config-row-queue')).toHaveCount(0); await expect(page.getByTestId('nav-config-row-milestones')).toHaveCount(0); await expect(page.getByTestId('nav-config-row-iterations')).toHaveCount(0); - await expect(page.getByTestId('nav-config-row-test-cases')).toBeVisible(); - await expect(page.getByTestId('nav-config-toggle-test-cases')).toHaveCount(0); + await expect(page.getByTestId('nav-config-row-test-cases')).toHaveCount(0); + await expect(page.getByTestId('nav-config-section-tests')).toHaveCount(0); // Switching collections from the config page keeps a working URL. await page.getByTestId('workspace-collection-select').click(); @@ -204,7 +208,7 @@ test.describe('Workspace navigation configuration', () => { await expect(page.getByTestId('workspace-nav-tree')).toBeVisible(); }); - test('global collections configure views from the switcher, gated to the creator', async ({ + test('global collections configure views from the sidebar, gated to the creator', async ({ page, browser, request, @@ -218,24 +222,30 @@ test.describe('Workspace navigation configuration', () => { ql_query: '', }); - // The creator gets a gear in the view switcher leading to the global - // config surface, which exposes only the views section. + // The creator configures the collection from its left navigation, not + // from the board toolbar. await page.goto(`/collections/${collection.id}/board`); await expect(page.getByTestId('board-view-switcher')).toBeVisible(); - await expect(page.getByTestId('collection-nav-config-button')).toBeVisible(); - await page.getByTestId('collection-nav-config-button').click(); + await expect(page.getByTestId('collection-nav-config-link')).toBeVisible(); + await expect(page.getByTestId('collection-nav-config-button')).toHaveCount(0); + await page.getByTestId('collection-nav-config-link').click(); await expect(page).toHaveURL(new RegExp(`/collections/${collection.id}/nav-config$`)); await expect(page.getByTestId('navigation-config-page')).toBeVisible(); + // The collection navigation stays visible while configuring it. + await expect(page.getByTestId('collection-nav-board')).toBeVisible(); await expect(page.getByTestId('nav-config-row-board')).toBeVisible(); await expect(page.getByTestId('nav-config-row-overview')).toHaveCount(0); await expect(page.getByTestId('nav-config-section-tools')).toHaveCount(0); - // Toggling a view off hides it from the switcher; reset restores it. + // Toggling a view off hides it from the switcher and the sidebar; reset + // restores it. await page.getByTestId('nav-config-toggle-backlog').click(); await expect(page.getByTestId('nav-config-row-backlog')).toHaveClass(/opacity-40/); + await expect(page.getByTestId('collection-nav-backlog')).toHaveCount(0); await page.goto(`/collections/${collection.id}/board`); await expect(page.getByTestId('board-view-switcher')).toBeVisible(); await expect(page.getByTestId('switcher-view-backlog')).toHaveCount(0); + await expect(page.getByTestId('collection-nav-backlog')).toHaveCount(0); await page.goto(`/collections/${collection.id}/nav-config`); await page.getByTestId('nav-config-reset').click(); @@ -243,8 +253,8 @@ test.describe('Workspace navigation configuration', () => { await page.goto(`/collections/${collection.id}/board`); await expect(page.getByTestId('switcher-view-backlog')).toBeVisible(); - // A user who cannot edit the collection gets neither the gear nor the - // Configure button. + // A user who cannot edit the collection gets neither the sidebar entry + // nor the Configure button. const otherData = generateUser(suffix); await createUserViaAPI(request, otherData); let otherContext; @@ -265,7 +275,7 @@ test.describe('Workspace navigation configuration', () => { const otherPage = await otherContext.newPage(); await otherPage.goto(`/collections/${collection.id}/board`); await expect(otherPage.getByTestId('board-view-switcher')).toBeVisible(); - await expect(otherPage.getByTestId('collection-nav-config-button')).toHaveCount(0); + await expect(otherPage.getByTestId('collection-nav-config-link')).toHaveCount(0); } finally { await otherContext?.close(); } @@ -326,4 +336,72 @@ test.describe('Workspace navigation configuration', () => { await viewerContext?.close(); } }); + + test('toggling and resetting navigation keeps the board columns', async ({ page, request }) => { + await authenticateAdminRequest(request); + const stamp = Date.now(); + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`nav-config-columns-${stamp}`) + ); + const collection = await createCollectionViaAPI(request, { + name: `Nav columns ${stamp}`, + workspace_id: workspace.id, + ql_query: '', + }); + + const statusesResponse = await request.get(`/api/v2/workspaces/${workspace.id}/statuses`, { + headers: SEC_FETCH, + }); + expect(statusesResponse.ok()).toBeTruthy(); + const statusesBody = await statusesResponse.json(); + const statuses: Array<{ id: number; name: string }> = statusesBody.data ?? statusesBody; + const status = statuses.find((entry) => entry.name === 'Open') ?? statuses[0]; + expect(status).toBeDefined(); + + // Seed the collection with a configured board column so the navigation + // page has something to lose. + const boardConfig = await request.put( + `/api/v2/collections/${collection.id}/board-configuration`, + { + headers: SEC_FETCH, + data: { + columns: [ + { + name: 'In progress', + display_order: 0, + wip_limit: null, + color: '#123456', + status_ids: [status.id], + }, + ], + backlog_status_ids: [], + list_columns: [], + card_fields: [], + }, + } + ); + expect(boardConfig.ok(), `create board config failed (${boardConfig.status()})`).toBeTruthy(); + + // The board column element carries the status id; an sr-only span shares + // the data-testid, so target the stable element id instead. + const column = page.locator(`#board-column-status-${status.id}`); + await page.goto(`/workspaces/${workspace.id}/collections/${collection.id}/board`); + await expect(column).toBeVisible(); + await expect(column).toContainText('In progress'); + + // Toggling a view must not hit the strict decoder, which is what + // happened when the page sent the fetched columns back with it. + await page.goto(`/workspaces/${workspace.id}/collections/${collection.id}/nav-config`); + await page.getByTestId('nav-config-toggle-list').click(); + await expect(page.getByTestId('nav-config-row-list')).toHaveClass(/opacity-40/); + await expect(page.locator('[data-testid="toast"][data-toast-variant="error"]')).toHaveCount(0); + + // Reset is a merge patch: the board configuration survives it. + await page.getByTestId('nav-config-reset').click(); + await expect(page.getByTestId('nav-config-inherited-badge')).toBeVisible(); + await page.goto(`/workspaces/${workspace.id}/collections/${collection.id}/board`); + await expect(column).toBeVisible(); + await expect(column).toContainText('In progress'); + }); }); diff --git a/e2e/tests/notifications.spec.ts b/e2e/tests/notifications.spec.ts index eb4c4f0..8624a6b 100644 --- a/e2e/tests/notifications.spec.ts +++ b/e2e/tests/notifications.spec.ts @@ -42,6 +42,19 @@ test.describe('Notifications: toast + live item comments', () => { return { context, page: await context.newPage() }; } + // The SSE endpoint shares the /api/notifications prefix, so the initial + // inbox wait must exclude it. Matching it would resolve on the stream + // connection and let the test create notifications while the first list + // snapshot is still in flight, so the snapshot can swallow the toast. + const waitForInboxSnapshot = (page) => + page.waitForResponse( + (response) => + response.request().method() === 'GET' && + response.url().includes('/api/notifications') && + !response.url().includes('/api/notifications/events') && + response.status() === 200 + ); + test('opening an item marks an older notification beyond the default page as read', async ({ page, request, @@ -81,12 +94,7 @@ test.describe('Notifications: toast + live item comments', () => { expect(response.ok(), await response.text()).toBeTruthy(); } - const initialInbox = page.waitForResponse( - (response) => - response.request().method() === 'GET' && - response.url().includes('/api/notifications') && - response.status() === 200 - ); + const initialInbox = waitForInboxSnapshot(page); await page.goto('/'); await expect(page.locator('#global-create-button')).toBeVisible({ timeout: 15_000 }); await initialInbox; @@ -124,9 +132,7 @@ test.describe('Notifications: toast + live item comments', () => { test.setTimeout(90_000); const { context, page } = await openToastPage(browser, request); try { - const initialInbox = page.waitForResponse( - (response) => response.url().includes('/api/notifications') && response.status() === 200 - ); + const initialInbox = waitForInboxSnapshot(page); // The toast is driven by the SSE invalidation stream. Create the // notification only after the stream is connected so the change event is // delivered instead of falling into the snapshot/subscription gap. @@ -175,9 +181,7 @@ test.describe('Notifications: toast + live item comments', () => { test.setTimeout(90_000); const { context, page } = await openToastPage(browser, request); try { - const initialInbox = page.waitForResponse( - (response) => response.url().includes('/api/notifications') && response.status() === 200 - ); + const initialInbox = waitForInboxSnapshot(page); // Wait for the SSE stream before creating notifications so the toast // invalidation is not lost in the snapshot/subscription gap. const streamConnected = page.waitForResponse((response) => diff --git a/e2e/tests/plugin-asset-auth.spec.ts b/e2e/tests/plugin-asset-auth.spec.ts new file mode 100644 index 0000000..97971d0 --- /dev/null +++ b/e2e/tests/plugin-asset-auth.spec.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { authenticateAdminRequest } from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/context-path"; +import { deleteCapabilityPlugins, installAssetPlugin } from "../fixtures/plugin-zip"; + +const BASE_URL = process.env.BASE_URL || "http://localhost:8080"; +const PLUGIN_PREFIX = "asset-auth"; + +function html(testId: string, label: string): string { + return `
${label}
`; +} + +// A plugin asset's auth comes from its manifest policy, not from the route +// table. The entry document and its subresources must share an auth bucket. +test("plugin assets enforce their declared auth policy", async ({ page, request, browser }) => { + test.setTimeout(90_000); + + await authenticateAdminRequest(request); + + const suffix = `${Date.now()}${randomUUID().slice(0, 4)}`; + const pluginName = `${PLUGIN_PREFIX}-${suffix}`; + + await deleteCapabilityPlugins(request, PLUGIN_PREFIX); + try { + await installAssetPlugin( + request, + pluginName, + [ + { path: "public/*", auth: "none" }, + { path: "admin/*", auth: "admin" }, + ], + { + "public/open.html": html("public-asset", "public asset"), + "admin/secret.html": html("admin-asset", "admin asset"), + "internal/secret.html": html("internal-asset", "internal asset"), + }, + ); + + // An authenticated admin can load the admin-only asset. + const adminResponse = await page.goto( + `${BASE_URL}/api/plugins/${pluginName}/assets/admin/secret.html`, + ); + expect(adminResponse?.status()).toBe(200); + await expect(page.getByTestId("admin-asset")).toBeVisible(); + + // A fresh, anonymous context can load the public asset... + const anonymous = await browser.newContext({ + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }); + try { + const anonPage = await anonymous.newPage(); + + const publicResponse = await anonPage.goto( + `${BASE_URL}/api/plugins/${pluginName}/assets/public/open.html`, + ); + expect(publicResponse?.status()).toBe(200); + await expect(anonPage.getByTestId("public-asset")).toBeVisible(); + + // ...but not the admin asset, and not an undeclared asset, which + // defaults to session auth. + const adminDenied = await anonPage.goto( + `${BASE_URL}/api/plugins/${pluginName}/assets/admin/secret.html`, + ); + expect(adminDenied?.status()).toBe(401); + + const internalDenied = await anonPage.goto( + `${BASE_URL}/api/plugins/${pluginName}/assets/internal/secret.html`, + ); + expect(internalDenied?.status()).toBe(401); + } finally { + await anonymous.close(); + } + } finally { + await deleteCapabilityPlugins(request, pluginName); + } +}); diff --git a/e2e/tests/plugin-permission-badge.spec.ts b/e2e/tests/plugin-permission-badge.spec.ts new file mode 100644 index 0000000..bf4d1c7 --- /dev/null +++ b/e2e/tests/plugin-permission-badge.spec.ts @@ -0,0 +1,42 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "../fixtures/errors"; +import { + deleteCapabilityPlugins, + installPermissionPlugin, +} from "../fixtures/plugin-zip"; + +// WI-1821: a plugin declares the host functions it may call, and the module +// settings list shows each requested permission and whether it is in effect. +test("module settings shows the declared host-function permissions", async ({ + page, + request, + allowConsoleError, +}) => { + // Pre-existing: the instance-id endpoint is only configured when license + // enforcement is on, so a fresh e2e server answers 500. + allowConsoleError(/\/api\/plugins\/instance-id/); + allowConsoleError(/Failed to load instance ID/); + const name = `permissions-${randomUUID().slice(0, 8)}`; + try { + await installPermissionPlugin(request, name, [ + "process.exec", + "network.egress", + ]); + + await page.goto("/admin/modules"); + + // normalizePermissions sorts the catalog, so network.egress precedes + // process.exec. Both are granted because license enforcement is off. + const network = page.getByTestId(`plugin-permission-${name}-0`); + await expect(network).toHaveAttribute("data-permission", "network.egress"); + await expect(network).toHaveAttribute("data-granted", "true"); + await expect(network).toHaveText("network.egress"); + + const process = page.getByTestId(`plugin-permission-${name}-1`); + await expect(process).toHaveAttribute("data-permission", "process.exec"); + await expect(process).toHaveAttribute("data-granted", "true"); + await expect(process).toHaveText("process.exec"); + } finally { + await deleteCapabilityPlugins(request, name); + } +}); diff --git a/e2e/tests/plugin-route-auth-badge.spec.ts b/e2e/tests/plugin-route-auth-badge.spec.ts new file mode 100644 index 0000000..e0a2414 --- /dev/null +++ b/e2e/tests/plugin-route-auth-badge.spec.ts @@ -0,0 +1,41 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "../fixtures/errors"; +import { + deleteCapabilityPlugins, + installRouteAuthPlugin, +} from "../fixtures/plugin-zip"; + +// WI-1820: a plugin route declares its credential in the manifest, and the +// module settings list shows the resolved auth per route. +test("module settings shows the declared auth badge per plugin route", async ({ + page, + request, + allowConsoleError, +}) => { + // Pre-existing: the instance-id endpoint is only configured when license + // enforcement is on, so a fresh e2e server answers 500. + allowConsoleError(/\/api\/plugins\/instance-id/); + allowConsoleError(/Failed to load instance ID/); + const name = `route-auth-${randomUUID().slice(0, 8)}`; + try { + await installRouteAuthPlugin(request, name, [ + { method: "GET", path: "/public", auth: "none" }, + { method: "GET", path: "/private", auth: "session" }, + { method: "GET", path: "/admin", auth: "admin" }, + ]); + + await page.goto("/admin/modules"); + + await expect(page.getByTestId(`plugin-route-auth-${name}-0`)).toHaveText( + "Public", + ); + await expect(page.getByTestId(`plugin-route-auth-${name}-1`)).toHaveText( + "Session", + ); + await expect(page.getByTestId(`plugin-route-auth-${name}-2`)).toHaveText( + "Admin", + ); + } finally { + await deleteCapabilityPlugins(request, name); + } +}); diff --git a/e2e/tests/portal-asset-report-drop.spec.ts b/e2e/tests/portal-asset-report-drop.spec.ts index d7e5c68..e44eebd 100644 --- a/e2e/tests/portal-asset-report-drop.spec.ts +++ b/e2e/tests/portal-asset-report-drop.spec.ts @@ -37,6 +37,14 @@ test('drops an asset report onto a portal section from the customize panel', asy expect(assetSetResponse.ok(), `create asset set: ${await assetSetResponse.text()}`).toBeTruthy(); const assetSet = (await assetSetResponse.json()).data; + // The set must carry a portal-access grant or its reports stay hidden from + // the portal catalog. + const portalAccessResponse = await request.put(`/api/v2/asset-sets/${assetSet.id}/portal-access`, { + headers: SEC_FETCH, + data: { enabled: true }, + }); + expect(portalAccessResponse.ok(), `grant portal access: ${await portalAccessResponse.text()}`).toBeTruthy(); + const reportResponse = await request.post(`/api/channels/${channel.channelId}/asset-reports`, { headers: SEC_FETCH, data: { diff --git a/e2e/tests/portal-asset-report-public-dto.spec.ts b/e2e/tests/portal-asset-report-public-dto.spec.ts index b626c09..4012a6e 100644 --- a/e2e/tests/portal-asset-report-public-dto.spec.ts +++ b/e2e/tests/portal-asset-report-public-dto.spec.ts @@ -61,6 +61,14 @@ test('anonymous bootstrap hides reports and a portal customer receives the safe expect(assetSetResponse.ok(), `create asset set: ${await assetSetResponse.text()}`).toBeTruthy(); const assetSet = (await assetSetResponse.json()).data; + // The set must carry a portal-access grant or its reports stay hidden from + // the portal catalog. + const portalAccessResponse = await request.put(`/api/v2/asset-sets/${assetSet.id}/portal-access`, { + headers: SEC_FETCH, + data: { enabled: true }, + }); + expect(portalAccessResponse.ok(), `grant portal access: ${await portalAccessResponse.text()}`).toBeTruthy(); + const reportResponse = await request.post(`/api/channels/${channel.id}/asset-reports`, { headers: SEC_FETCH, data: { diff --git a/e2e/tests/portal-request-prefill.spec.ts b/e2e/tests/portal-request-prefill.spec.ts index 747f9fd..2a52bc5 100644 --- a/e2e/tests/portal-request-prefill.spec.ts +++ b/e2e/tests/portal-request-prefill.spec.ts @@ -74,6 +74,14 @@ test('asset report row action links to the prefilled request form', async ({ pag expect(setResponse.ok(), `create asset set: ${await setResponse.text()}`).toBeTruthy(); const assetSet = (await setResponse.json()).data; + // The set must carry a portal-access grant or its reports stay hidden from + // the portal catalog. + const portalAccessResponse = await request.put(`/api/v2/asset-sets/${assetSet.id}/portal-access`, { + headers: SEC_FETCH, + data: { enabled: true }, + }); + expect(portalAccessResponse.ok(), `grant portal access: ${await portalAccessResponse.text()}`).toBeTruthy(); + const typeResponse = await request.post(`/api/v2/asset-sets/${assetSet.id}/types`, { headers: SEC_FETCH, data: { name: `Row server ${stamp}` }, diff --git a/e2e/tests/ql-builder-value-help.spec.ts b/e2e/tests/ql-builder-value-help.spec.ts index 6c222e8..ea0001f 100644 --- a/e2e/tests/ql-builder-value-help.spec.ts +++ b/e2e/tests/ql-builder-value-help.spec.ts @@ -23,14 +23,14 @@ test('searches custom multiselect values in the QL builder', async ({ page, requ const filter = page.getByTestId('global-search-dynamic-filter-0'); await filter.getByTestId('field-selector-trigger').click(); - await page.getByTestId(`field-option-cf_${fieldName}`).click(); + await page.getByTestId(`field-option-cfid_${fieldID}`).click(); const valueSearch = page.getByTestId('global-search-dynamic-filter-0-value-search'); await valueSearch.click(); await valueSearch.fill('operations'); await page.getByTestId('global-search-dynamic-filter-0-value-option-2').click(); - await expect(page.getByTestId('ql-query-summary')).toHaveText(`\`cf_${fieldName}\` = "2"`); + await expect(page.getByTestId('ql-query-summary')).toHaveText(`\`cfid_${fieldID}\` = "2"`); await expect(valueSearch).toHaveValue('Operations approvers'); } finally { if (fieldID) await deleteCustomFieldViaAPI(request, fieldID); diff --git a/e2e/tests/raw-markdown-editor-plugin.spec.ts b/e2e/tests/raw-markdown-editor-plugin.spec.ts new file mode 100644 index 0000000..dd166bf --- /dev/null +++ b/e2e/tests/raw-markdown-editor-plugin.spec.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import { + authenticateAdminRequest, + createItemViaAPI, + createWorkspaceViaAPI, +} from "../fixtures/api-helpers"; +import { expect, externalPath, test } from "../fixtures/context-path"; +import { + deleteCapabilityPlugins, + installEditorPluginFromCore, +} from "../fixtures/plugin-zip"; +import { generateItem, generateWorkspace } from "../fixtures/test-data"; + +const PLUGIN_NAME = "markdown-raw-editor"; +// Extension id "raw-markdown" on plugin "markdown-raw-editor". +const PROVIDER_TESTID = "plugin-editor-provider-raw-markdown"; + +// Constructs Milkdown's serializer rewrites: underscore emphasis, tilde fences, +// and star bullets. The raw editor must preserve them byte for byte. +const RAW_MARKDOWN = [ + "# Raw source", + "", + "_underscore emphasis_ stays put.", + "", + "~~~text", + "fenced with tildes", + "~~~", + "", + "* star bullet", + "", +].join("\n"); + +test("the raw markdown editor round-trips exact source through the item description", async ({ + page, + request, +}) => { + test.setTimeout(90_000); + const coreDir = process.env.E2E_CORE_DIR; + expect(coreDir, "E2E_CORE_DIR is set by run-e2e.sh").toBeTruthy(); + + const suffix = `rawmd${Date.now()}${randomUUID().slice(0, 4)}`; + try { + await authenticateAdminRequest(request); + await deleteCapabilityPlugins(request, PLUGIN_NAME); + await installEditorPluginFromCore(request, coreDir as string, PLUGIN_NAME); + + const workspace = await createWorkspaceViaAPI(request, generateWorkspace(suffix)); + const itemData = generateItem(workspace.id, suffix); + const item = await createItemViaAPI(request, workspace.id, { + title: itemData.title, + description: RAW_MARKDOWN, + }); + + await page.goto(`/workspaces/${workspace.id}/items/${item.id}`); + const display = page.getByTestId("item-description-display"); + await expect(display).toBeVisible({ timeout: 15_000 }); + await display.click(); + + const provider = page.getByTestId(PROVIDER_TESTID); + await expect(provider).toBeVisible({ timeout: 15_000 }); + await provider.click(); + + const frame = page.frameLocator('[data-testid="plugin-editor-frame"]'); + const textarea = frame.getByTestId("raw-markdown-textarea"); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + // The host spinner must clear once the ready handshake completes. + await expect(page.getByTestId("plugin-editor-loading")).toHaveCount(0); + // The WYSIWYG serializer would have normalized this source; the raw + // editor must show exactly what the API stored. + await expect(textarea).toHaveValue(RAW_MARKDOWN); + // The plugin must not add a second save path; the surface owns Save. + await expect(frame.getByTestId("raw-markdown-save")).toHaveCount(0); + + const edited = `${RAW_MARKDOWN}\nEdited in raw mode.\n`; + await textarea.fill(edited); + + // Live sync mirrors the edit into the host's content; the surface's own + // Save button persists it through the normal item API. + await page.getByTestId("item-description-save").click(); + + await expect + .poll( + async () => { + const response = await request.get(externalPath(`/api/v2/items/${item.id}`)); + const body = await response.json(); + return body.data?.description; + }, + { timeout: 15_000 }, + ) + .toBe(edited); + } finally { + await deleteCapabilityPlugins(request, PLUGIN_NAME); + } +}); diff --git a/e2e/tests/recurrence-workflow.spec.ts b/e2e/tests/recurrence-workflow.spec.ts index 3a1e9bf..4c9b999 100644 --- a/e2e/tests/recurrence-workflow.spec.ts +++ b/e2e/tests/recurrence-workflow.spec.ts @@ -8,9 +8,7 @@ import { shot } from "../helpers/screenshot"; test("creates, persists, administers, and deletes an item recurrence rule", async ({ page, request, - allowConsoleError, }) => { - allowConsoleError(/\/api\/logbook\//); const stamp = Date.now(); const workspace = await createWorkspaceViaAPI(request, { diff --git a/e2e/tests/refactor-shots.spec.ts b/e2e/tests/refactor-shots.spec.ts index ea9c452..f8bca5e 100644 --- a/e2e/tests/refactor-shots.spec.ts +++ b/e2e/tests/refactor-shots.spec.ts @@ -124,14 +124,11 @@ test.describe('refactor-shots', () => { test.skip(!PHASE || !TARGET, 'set REFACTOR_PHASE and REFACTOR_TARGET to use this harness'); test(`capture ${TARGET} (${PHASE})`, async ({ page, request, allowConsoleError }) => { - // Same environmental noise allowlist as button-smoke: optional services - // that 404 in dev (logbook, attachment-settings) and rate-limit retries. + // Same environmental noise allowlist as button-smoke: the optional + // attachment-settings backend 404s in dev, plus rate-limit retries. allowConsoleError(/Failed to load resource.*429 \(Too Many Requests\)/); - allowConsoleError(/\/api\/logbook\//); allowConsoleError(/\/api\/attachment-settings\/status/); allowConsoleError(/Failed to load attachment status/); - allowConsoleError(/Failed to load buckets/); - allowConsoleError(/Failed to load all documents/); expect(['before', 'after']).toContain(PHASE); const target = TARGETS[TARGET]; diff --git a/e2e/tests/reference-data-invalidation.spec.ts b/e2e/tests/reference-data-invalidation.spec.ts new file mode 100644 index 0000000..48ba2c1 --- /dev/null +++ b/e2e/tests/reference-data-invalidation.spec.ts @@ -0,0 +1,129 @@ +import { + createIterationViaAPI, + createItemViaAPI, + createWorkspaceViaAPI, + listIterationTypesViaAPI, +} from "../fixtures/api-helpers"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "../fixtures/context-path"; +import { + generateItem, + generateIteration, + generateMilestone, + generateWorkspace, +} from "../fixtures/test-data"; +import { BoardPage } from "../pages/board.page"; +import { ItemPage } from "../pages/item.page"; +import { IterationPage } from "../pages/iteration.page"; +import { MilestonePage } from "../pages/milestone.page"; + +// In-app navigation is the point: a full page load rebuilds the shared stores, +// which is exactly what masked the stale-cache bug. +async function clickWorkspaceNav(page: Page, testId: string) { + await page.getByTestId(testId).click(); +} + +async function createWorkspace(request: APIRequestContext) { + const workspace = generateWorkspace(); + const created = await createWorkspaceViaAPI(request, { + name: workspace.name, + key: workspace.key, + description: workspace.description ?? "reference data invalidation", + }); + return created.id as number; +} + +async function findMilestoneId( + request: APIRequestContext, + workspaceId: number, + name: string, +) { + const response = await request.get( + `/api/v2/workspaces/${workspaceId}/milestones`, + ); + const body = await response.json(); + const rows = (body.data ?? body) as Array<{ id: number; name: string }>; + return rows.find((row) => row.name === name)?.id; +} + +test.describe("Reference data invalidation", () => { + // NOTE: global iterations are shared across every workspace by design, so a + // workspace board can legitimately keep its filter bar after its own last + // iteration is deleted. Assertions below target the mutated entry, which is + // deterministic even while other specs create global iterations in parallel. + test("a milestone created on the milestones page is offered on an item", async ({ + page, + request, + }) => { + const workspaceId = await createWorkspace(request); + const itemData = generateItem(0, "ms-invalidate"); + await createItemViaAPI(request, workspaceId, { title: itemData.title }); + + // Prime the shared store from a board that has no milestones. + const boardPage = new BoardPage(page); + await boardPage.goto(String(workspaceId)); + await boardPage.verifyBoardVisible(); + + // Create the milestone through the app, then return to the item. + await clickWorkspaceNav(page, "workspace-nav-milestones"); + const milestonePage = new MilestonePage(page); + const milestone = generateMilestone("invalidate"); + await milestonePage.createMilestone({ name: milestone.name }); + const milestoneId = await findMilestoneId( + request, + workspaceId, + milestone.name, + ); + expect(milestoneId, "milestone was not created").toBeTruthy(); + + await clickWorkspaceNav(page, "workspace-nav-backlog"); + const itemPage = new ItemPage(page); + await itemPage.openItemDetailModal(itemData.title); + + const dialog = page.locator('[role="dialog"]'); + await dialog.locator('[data-testid="milestone-field"]').click(); + await expect( + page.locator(`[role="option"][data-option-id="${milestoneId}"]`), + ).toBeVisible(); + }); + + test("the board stops offering an iteration deleted on the iterations page", async ({ + page, + request, + }) => { + const workspaceId = await createWorkspace(request); + const types = await listIterationTypesViaAPI(request); + expect(types.length, "no iteration types seeded").toBeGreaterThan(0); + + const iteration = generateIteration("last"); + await createIterationViaAPI(request, { + ...iteration, + workspace_id: workspaceId, + type_id: types[0].id, + status: "active", + }); + + const boardPage = new BoardPage(page); + await boardPage.goto(String(workspaceId)); + await boardPage.verifyBoardVisible(); + await expect(page.getByTestId("board-iteration-filter")).toBeVisible(); + await expect( + page.getByTestId("board-iteration-filter").getByText(iteration.name), + ).toBeVisible(); + + // Delete the iteration through the app, then return to the board. + await clickWorkspaceNav(page, "workspace-nav-iterations"); + const iterationPage = new IterationPage(page); + await iterationPage.deleteIteration(iteration.name); + + await clickWorkspaceNav(page, "workspace-nav-board"); + await boardPage.verifyBoardVisible(); + await expect( + page.getByTestId("board-iteration-filter").getByText(iteration.name), + ).toHaveCount(0); + }); +}); diff --git a/e2e/tests/remaining-module-journeys.spec.ts b/e2e/tests/remaining-module-journeys.spec.ts index 0af86b5..ff9f9a9 100644 --- a/e2e/tests/remaining-module-journeys.spec.ts +++ b/e2e/tests/remaining-module-journeys.spec.ts @@ -11,272 +11,6 @@ const json = (route: Route, body: unknown, status = 200) => }); test.describe('Remaining 0.8.5 module journeys (WI-706)', () => { - test('Logbook creates, searches, edits, recovers from a visible error, and reloads persisted notes', async ({ - page, - allowConsoleError, - }) => { - allowConsoleError(/\/api\/logbook\/documents\/\d+/); - - type Bucket = { - id: number; - name: string; - description: string; - document_count: number; - }; - type Document = { - id: number; - bucket_id: number; - bucket_name: string; - title: string; - article: string; - source_type: 'note'; - status: 'ready'; - created_at: string; - updated_at: string; - content_type: null; - author: null; - has_thumbnail: false; - has_preview: false; - chunk_count: number; - retrieval_count: number; - }; - - const now = new Date().toISOString(); - const buckets: Bucket[] = []; - const documents: Document[] = []; - let nextBucketId = 41; - let nextDocumentId = 91; - let failNextSave = true; - - await page.route('**/api/shell-bootstrap', async (route) => { - const response = await route.fetch(); - const body = await response.json(); - await route.fulfill({ - response, - json: { - ...body, - features: { ...body.features, logbook_available: true }, - }, - }); - }); - - await page.route('**/api/logbook/**', async (route, request) => { - const url = new URL(request.url()); - const apiPath = url.pathname.slice(url.pathname.indexOf('/api/')); - const method = request.method(); - - if (apiPath === '/api/logbook/buckets' && method === 'GET') { - for (const bucket of buckets) { - bucket.document_count = documents.filter( - (document) => document.bucket_id === bucket.id - ).length; - } - await json(route, buckets); - return; - } - - if (apiPath === '/api/logbook/buckets' && method === 'POST') { - const payload = request.postDataJSON() as { - name: string; - description: string; - }; - const bucket = { - id: nextBucketId++, - name: payload.name, - description: payload.description, - document_count: 0, - }; - buckets.push(bucket); - documents.push({ - id: nextDocumentId++, - bucket_id: bucket.id, - bucket_name: bucket.name, - title: 'Unrelated operating note', - article: 'Noise used to prove search filtering.', - source_type: 'note', - status: 'ready', - created_at: now, - updated_at: now, - content_type: null, - author: null, - has_thumbnail: false, - has_preview: false, - chunk_count: 0, - retrieval_count: 0, - }); - await json(route, bucket, 201); - return; - } - - const noteMatch = apiPath.match(/^\/api\/logbook\/buckets\/(\d+)\/documents\/notes$/); - if (noteMatch && method === 'POST') { - const bucketId = Number(noteMatch[1]); - const bucket = buckets.find((candidate) => candidate.id === bucketId); - const payload = request.postDataJSON() as { - title: string; - content?: string; - }; - const document: Document = { - id: nextDocumentId++, - bucket_id: bucketId, - bucket_name: bucket?.name ?? '', - title: payload.title, - article: payload.content ?? '', - source_type: 'note', - status: 'ready', - created_at: now, - updated_at: now, - content_type: null, - author: null, - has_thumbnail: false, - has_preview: false, - chunk_count: 0, - retrieval_count: 0, - }; - documents.push(document); - await json(route, document, 201); - return; - } - - const bucketDocumentsMatch = apiPath.match(/^\/api\/logbook\/buckets\/(\d+)\/documents$/); - if (bucketDocumentsMatch && method === 'GET') { - const bucketId = Number(bucketDocumentsMatch[1]); - const data = documents.filter((document) => document.bucket_id === bucketId); - await json(route, { data, pagination: { total: data.length } }); - return; - } - - if (apiPath === '/api/logbook/documents' && method === 'GET') { - await json(route, { - data: documents, - pagination: { total: documents.length }, - }); - return; - } - - if (apiPath === '/api/logbook/search' && method === 'GET') { - const query = (url.searchParams.get('q') ?? '').toLowerCase(); - const bucketId = Number(url.searchParams.get('bucket_id') ?? 0); - await json( - route, - documents - .filter( - (document) => - (!bucketId || document.bucket_id === bucketId) && - document.title.toLowerCase().includes(query) - ) - .map((document) => ({ document_id: document.id })) - ); - return; - } - - if (/^\/api\/logbook\/buckets\/\d+\/actions$/.test(apiPath) && method === 'GET') { - await json(route, []); - return; - } - - const documentMatch = apiPath.match(/^\/api\/logbook\/documents\/(\d+)$/); - if (documentMatch) { - const document = documents.find((candidate) => candidate.id === Number(documentMatch[1])); - if (!document) { - await json(route, { error: 'document not found' }, 404); - return; - } - if (method === 'GET') { - await json(route, document); - return; - } - if (method === 'PUT') { - if (failNextSave) { - failNextSave = false; - await json(route, { error: 'Temporary Logbook failure' }, 503); - return; - } - const payload = request.postDataJSON() as { - title: string; - article: string; - }; - document.title = payload.title; - document.article = payload.article; - document.updated_at = new Date().toISOString(); - await json(route, document); - return; - } - } - - await json(route, { error: `Unhandled Logbook request: ${method} ${apiPath}` }, 500); - }); - - const bucketName = `Release notes ${Date.now()}`; - const noteTitle = `0.8.5 handoff ${Date.now()}`; - const revisedTitle = `${noteTitle} revised`; - - await page.goto('/logbook'); - await page.getByTestId('logbook-create-bucket').click(); - await page.getByTestId('logbook-bucket-name').fill(bucketName); - await page.locator('#bucket-description').fill('Release handoff knowledge'); - const bucketCreated = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - new URL(response.url()).pathname.endsWith('/api/logbook/buckets') - ); - await page.getByTestId('logbook-bucket-name').press('Enter'); - expect((await bucketCreated).status()).toBe(201); - - const bucket = buckets[0]; - if (!bucket) throw new Error('browser bucket creation did not persist'); - await expect(page.getByTestId(`logbook-bucket-${bucket.id}`)).toBeVisible(); - await page.getByTestId(`logbook-bucket-${bucket.id}`).click(); - await page.getByTestId('logbook-new-note').click(); - await page.getByTestId('logbook-note-title').fill(noteTitle); - const noteCreated = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - new URL(response.url()).pathname.endsWith( - `/api/logbook/buckets/${bucket.id}/documents/notes` - ) - ); - await page.getByTestId('logbook-note-create').click(); - expect((await noteCreated).status()).toBe(201); - - const createdNote = documents.find((document) => document.title === noteTitle); - if (!createdNote) throw new Error('browser note creation did not persist'); - await expect(page.getByTestId(`logbook-document-${createdNote.id}`)).toBeVisible(); - - await page.getByTestId('logbook-search').fill(noteTitle); - await expect(page.getByTestId(`logbook-document-${createdNote.id}`)).toBeVisible(); - await expect(page.getByTestId('logbook-document-91')).toBeHidden(); - - await page.getByTestId(`logbook-document-${createdNote.id}`).click(); - const title = page.getByTestId('logbook-document-title'); - await expect(title).toHaveValue(noteTitle); - await title.fill(revisedTitle); - const failedSave = page.waitForResponse( - (response) => - response.request().method() === 'PUT' && - new URL(response.url()).pathname.endsWith(`/api/logbook/documents/${createdNote.id}`) - ); - await page.getByTestId('logbook-document-save').click(); - expect((await failedSave).status()).toBe(503); - await expect(page.getByTestId('toast-message-error')).toContainText( - 'Temporary Logbook failure' - ); - await expect(title).toHaveValue(revisedTitle); - - const retry = page.waitForResponse( - (response) => - response.request().method() === 'PUT' && - new URL(response.url()).pathname.endsWith(`/api/logbook/documents/${createdNote.id}`) - ); - await page.getByTestId('logbook-document-save').click(); - expect((await retry).ok()).toBeTruthy(); - await expect(title).toHaveValue(revisedTitle); - - await page.reload(); - await expect(title).toHaveValue(revisedTitle); - await page.unrouteAll({ behavior: 'wait' }); - }); - test('collection UI creates a collection, searches its items, recovers from save failure, and persists edits', async ({ page, request, diff --git a/e2e/tests/roadmap-scheduling.spec.ts b/e2e/tests/roadmap-scheduling.spec.ts index 685836a..d491efe 100644 --- a/e2e/tests/roadmap-scheduling.spec.ts +++ b/e2e/tests/roadmap-scheduling.spec.ts @@ -3,13 +3,38 @@ import { createItemViaAPI, createWorkspaceViaAPI, } from "../fixtures/api-helpers"; +import type { Locator } from "@playwright/test"; import { expect, test } from "../fixtures/errors"; +// Collections are cross-workspace saved searches, so a stamp collision between +// parallel workers would leak items into another test's collection. Include the +// worker index so each test's query stays unique. +function uniqueStamp(): number { + return Date.now() * 10 + test.info().workerIndex; +} + +async function expectTodayMarkerVisible( + timeline: Locator, + todayLine: Locator, +): Promise { + await expect + .poll(async () => { + const viewport = await timeline.boundingBox(); + const marker = await todayLine.boundingBox(); + if (!viewport || !marker) return false; + return ( + marker.x >= viewport.x - 1 && + marker.x + marker.width <= viewport.x + viewport.width + 1 + ); + }) + .toBe(true); +} + test("keeps roadmap settings visible when an item has an old due date", async ({ page, request, }) => { - const stamp = Date.now(); + const stamp = uniqueStamp(); const workspace = await createWorkspaceViaAPI(request, { name: `Roadmap overdue ${stamp}`, key: `RO${stamp.toString().slice(-6)}`.toUpperCase(), @@ -70,7 +95,7 @@ test("keeps a five-year quarter horizon and offers a return-to-today control", a page, request, }) => { - const stamp = Date.now(); + const stamp = uniqueStamp(); const workspace = await createWorkspaceViaAPI(request, { name: `Roadmap horizon ${stamp}`, key: `RH${stamp.toString().slice(-6)}`.toUpperCase(), @@ -129,16 +154,82 @@ test("keeps a five-year quarter horizon and offers a return-to-today control", a await expect(returnToToday).toBeVisible(); await returnToToday.click(); await expect(returnToToday).toHaveCount(0); + await expectTodayMarkerVisible( + timeline, + page.getByTestId("roadmap-today-line").first(), + ); +}); + +test("week view returns to today instead of the earliest scheduled column", async ({ + page, + request, +}) => { + const stamp = uniqueStamp(); + const workspace = await createWorkspaceViaAPI(request, { + name: `Roadmap today ${stamp}`, + key: `RT${stamp.toString().slice(-6)}`.toUpperCase(), + description: "Playwright roadmap today coverage", + }); + // Scheduling an item months in the past prepends columns, so scrollLeft 0 + // sits well before today. + const past = new Date(); + past.setMonth(past.getMonth() - 4); + await createItemViaAPI(request, workspace.id, { + title: `Old scheduled item ${stamp}`, + start_date: past.toISOString(), + }); + const collection = await createCollectionViaAPI(request, { + name: `Roadmap today collection ${stamp}`, + workspace_id: workspace.id, + ql_query: `title ~ "${stamp}"`, + }); + const configuration = await request.put( + `/api/v2/collections/${collection.id}/board-configuration`, + { + headers: { "Sec-Fetch-Site": "same-origin" }, + data: { + columns: [], + backlog_status_ids: [], + list_columns: [], + card_fields: [], + roadmap_config: { + start_field_id: "start_date", + end_field_id: "", + dependency_link_type_id: null, + }, + show_rightmost_column_last_50: false, + }, + }, + ); + expect(configuration.ok()).toBeTruthy(); + + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto( + `/workspaces/${workspace.id}/collections/${collection.id}/roadmap`, + ); + await expect(page.getByTestId("roadmap-view")).toBeVisible(); + await page.getByTestId("roadmap-zoom-week").click(); + + const timeline = page.getByTestId("roadmap-timeline-scroll"); + await expect(timeline).toBeVisible(); + const todayLine = page.getByTestId("roadmap-today-line").first(); + await expect(todayLine).toHaveCount(1); + + await page.getByTestId("roadmap-today").click(); + + // The today marker lands inside the scroll viewport rather than in the + // prepended past columns. + await expectTodayMarkerVisible(timeline, todayLine); await expect .poll(() => timeline.evaluate((element) => element.scrollLeft)) - .toBe(0); + .toBeGreaterThan(0); }); test("previews and schedules an undated roadmap item with one click", async ({ page, request, }) => { - const stamp = Date.now(); + const stamp = uniqueStamp(); const workspace = await createWorkspaceViaAPI(request, { name: `Roadmap scheduling ${stamp}`, key: `RS${stamp.toString().slice(-6)}`.toUpperCase(), @@ -235,7 +326,7 @@ test("roll-down keeps inner child dates and persists only overflow after a paren page, request, }) => { - const stamp = Date.now(); + const stamp = uniqueStamp(); const workspace = await createWorkspaceViaAPI(request, { name: `Roadmap roll-down ${stamp}`, key: `RD${stamp.toString().slice(-6)}`.toUpperCase(), diff --git a/e2e/tests/rtl-bidi.spec.ts b/e2e/tests/rtl-bidi.spec.ts new file mode 100644 index 0000000..724278e --- /dev/null +++ b/e2e/tests/rtl-bidi.spec.ts @@ -0,0 +1,167 @@ +import type { BrowserContext, APIRequestContext } from '@playwright/test'; +import { + createItemViaAPI, + createUserViaAPI, + createWorkspaceViaAPI, +} from '../fixtures/api-helpers'; +import { expect, test } from '../fixtures/context-path'; +import { generateUser, generateWorkspace } from '../fixtures/test-data'; + +const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; + +const ARABIC_TITLE = 'ØšŲ†ŲˆØ§Ų† ØšØąØ¨ŲŠ Ų„Ų„Ø§ØŽØĒØ¨Ø§Øą'; +const ENGLISH_TITLE = 'English title stays left aligned'; + +async function grantSystemAdmin(request: APIRequestContext, userId: number) { + const permissionsResponse = await request.get('/api/permissions', { + headers: SEC_FETCH, + }); + expect(permissionsResponse.ok()).toBeTruthy(); + const permissions = await permissionsResponse.json(); + const systemAdmin = permissions.find( + (permission: { permission_key: string }) => + permission.permission_key === 'system.admin', + ); + expect(systemAdmin, 'system.admin permission').toBeDefined(); + const grant = await request.post('/api/permissions/global/grant', { + headers: SEC_FETCH, + data: { user_id: userId, permission_id: systemAdmin.id }, + }); + expect(grant.ok()).toBeTruthy(); +} + +function titleLocator(page: import('@playwright/test').Page) { + return page + .getByTestId('item-title-edit') + .or(page.getByTestId('item-title-readonly')); +} + +/** + * Computed `text-align` stays logical (`start`), so also measure where the text + * actually sits inside the element: an RTL run hugs the right edge, an LTR run + * the left. + */ +async function readTitleAlignment( + page: import('@playwright/test').Page, +) { + return titleLocator(page).evaluate((el) => { + const range = document.createRange(); + range.selectNodeContents(el); + const textRect = range.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + return { + direction: getComputedStyle(el).direction, + textAlign: getComputedStyle(el).textAlign, + leftGap: textRect.left - elRect.left, + rightGap: elRect.right - textRect.right, + }; + }); +} + +test.describe('RTL / LTR bidi text placement', () => { + test('content follows its own direction in an RTL interface', async ({ + browser, + request, + }) => { + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace('rtl-bidi'), + ); + const arabicItem = await createItemViaAPI(request, workspace.id, { + title: ARABIC_TITLE, + }); + const englishItem = await createItemViaAPI(request, workspace.id, { + title: ENGLISH_TITLE, + }); + + const userData = generateUser('rtl-bidi'); + const admin = await createUserViaAPI(request, userData); + await grantSystemAdmin(request, admin.id); + const regional = await request.put( + `/api/users/${admin.id}/regional-settings`, + { + headers: SEC_FETCH, + data: { language: 'ar', timezone: 'UTC' }, + }, + ); + expect(regional.ok()).toBeTruthy(); + + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ baseURL: process.env.BASE_URL }); + const login = await context.request.post('/api/auth/login', { + headers: SEC_FETCH, + data: { + email_or_username: userData.username, + password: userData.password_hash, + remember_me: false, + }, + }); + expect(login.ok()).toBeTruthy(); + const page = await context.newPage(); + + // Arabic UI + Arabic title: the title must sit on the right. + await page.goto(`/workspaces/${workspace.id}/items/${arabicItem.id}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.documentElement.dir)) + .toBe('rtl'); + + const arabicTitle = titleLocator(page); + await expect(arabicTitle).toHaveAttribute('dir', 'auto'); + const arabic = await readTitleAlignment(page); + expect(arabic.direction).toBe('rtl'); + expect(arabic.textAlign).toBe('start'); + expect(arabic.rightGap).toBeLessThan(arabic.leftGap); + + // Arabic UI + English title: the title must stay left-to-right. + await page.goto(`/workspaces/${workspace.id}/items/${englishItem.id}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible(); + + const englishTitle = titleLocator(page); + await expect(englishTitle).toHaveAttribute('dir', 'auto'); + const english = await readTitleAlignment(page); + expect(english.direction).toBe('ltr'); + expect(english.textAlign).toBe('start'); + expect(english.leftGap).toBeLessThan(english.rightGap); + } finally { + await context?.close(); + await request.delete(`/api/users/${admin.id}`, { headers: SEC_FETCH }); + await request.delete(`/api/v2/workspaces/${workspace.id}`, { + headers: SEC_FETCH, + }); + } + }); + + test('RTL content renders right-to-left in an LTR interface', async ({ + page, + request, + }) => { + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace('ltr-bidi'), + ); + try { + const arabicItem = await createItemViaAPI(request, workspace.id, { + title: ARABIC_TITLE, + }); + + await page.goto(`/workspaces/${workspace.id}/items/${arabicItem.id}`); + await expect(page.getByTestId('item-detail-ready')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.documentElement.dir)) + .toBe('ltr'); + + const title = titleLocator(page); + await expect(title).toHaveAttribute('dir', 'auto'); + const arabic = await readTitleAlignment(page); + expect(arabic.direction).toBe('rtl'); + expect(arabic.textAlign).toBe('start'); + expect(arabic.rightGap).toBeLessThan(arabic.leftGap); + } finally { + await request.delete(`/api/v2/workspaces/${workspace.id}`, { + headers: SEC_FETCH, + }); + } + }); +}); diff --git a/e2e/tests/screens-field-editor.spec.ts b/e2e/tests/screens-field-editor.spec.ts new file mode 100644 index 0000000..d93df0e --- /dev/null +++ b/e2e/tests/screens-field-editor.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '../fixtures/context-path'; + +const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; + +/** + * The screens field-layout editor is a shared component (also opened from the + * workflow designer's transition inspector). This spec guards the admin-page + * wiring: the row action opens it, it loads the screen's fields, and the header + * Save persists through the component's exported save(). + */ +test.describe('Screens admin field editor', () => { + test('opens the layout editor from the row actions and saves the fields', async ({ + page, + request, + }) => { + test.setTimeout(90_000); + + const stamp = Date.now(); + const name = `e2e-screen-${stamp}`; + + const createResp = await request.post('/api/v2/screens', { + headers: SEC_FETCH, + data: { name, description: 'e2e field editor' }, + }); + expect( + createResp.ok(), + `create screen: ${createResp.status()} ${await createResp.text()}` + ).toBeTruthy(); + const screen = (await createResp.json()).data; + + const fieldsResp = await request.put(`/api/v2/screens/${screen.id}/fields`, { + headers: SEC_FETCH, + data: { + fields: [ + { + field_type: 'system', + field_identifier: 'title', + display_order: 0, + is_required: true, + field_width: 'full', + }, + { + field_type: 'system', + field_identifier: 'priority', + display_order: 1, + is_required: false, + field_width: 'half', + }, + ], + }, + }); + expect( + fieldsResp.ok(), + `seed fields: ${fieldsResp.status()} ${await fieldsResp.text()}` + ).toBeTruthy(); + + await page.goto('/admin/screens'); + await page.getByTestId(`screen-actions-${screen.id}`).click(); + await page.getByTestId('screen-action-fields').click(); + + await expect(page.getByTestId('screen-field-layout-editor')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('screen-field-priority')).toBeVisible(); + + const savePromise = page.waitForResponse( + (resp) => + resp.url().includes(`/screens/${screen.id}/fields`) && resp.request().method() === 'PUT' + ); + await page.getByTestId('screens-save-fields').click(); + const saveResp = await savePromise; + expect(saveResp.ok(), `save fields: ${saveResp.status()}`).toBeTruthy(); + + // Saving returns to the screen list. + await expect(page.getByTestId('screen-field-layout-editor')).toHaveCount(0); + }); +}); diff --git a/e2e/tests/test-reports-coverage-header.spec.ts b/e2e/tests/test-reports-coverage-header.spec.ts new file mode 100644 index 0000000..e262194 --- /dev/null +++ b/e2e/tests/test-reports-coverage-header.spec.ts @@ -0,0 +1,44 @@ +import { createWorkspaceViaAPI } from "../fixtures/api-helpers"; +import { expect, test } from "../fixtures/context-path"; +import { generateWorkspace } from "../fixtures/test-data"; + +/** + * The coverage tab header places two labelled Selects and a label-less + * Configure button in a single row. The button's invisible label must match the + * selects' label so all controls share a vertical center. When the spacer did + * not match the FormField label, the button rendered lower than the selects. + */ +test.describe("Test reports coverage header alignment", () => { + test("Configure button is vertically centered with the collection and filter selects", async ({ + page, + request, + }) => { + const workspace = await createWorkspaceViaAPI(request, generateWorkspace()); + + await page.goto(`/workspaces/${workspace.id}/tests/reports?subtab=coverage`); + + const configure = page.getByTestId("coverage-configure"); + await expect(configure).toBeVisible({ timeout: 15_000 }); + + const buttonBox = await configure.boundingBox(); + expect(buttonBox).not.toBeNull(); + const buttonCenter = buttonBox!.y + buttonBox!.height / 2; + + for (const selectId of [ + "coverage-collection-select", + "coverage-filter-select", + ]) { + const select = page.locator(`#${selectId}`); + await expect(select).toBeVisible(); + + const selectBox = await select.boundingBox(); + expect(selectBox).not.toBeNull(); + const selectCenter = selectBox!.y + selectBox!.height / 2; + + expect( + Math.abs(buttonCenter - selectCenter), + `Configure button center (${buttonCenter}) should align with #${selectId} center (${selectCenter})`, + ).toBeLessThanOrEqual(2); + } + }); +}); diff --git a/e2e/tests/test-runs.spec.ts b/e2e/tests/test-runs.spec.ts index 22a02f5..65e561a 100644 --- a/e2e/tests/test-runs.spec.ts +++ b/e2e/tests/test-runs.spec.ts @@ -407,7 +407,11 @@ test.describe("Test management browser lifecycle", () => { ); await page.getByTestId("dialog-confirm").click(); await expectResponseOK(await endResponsePromise, "end test run"); - await expect(page.getByTestId(`test-run-row-${run.id}`)).toHaveCount(0); + // Ended runs stay listed (the API includes them); the status flips to + // Completed instead of the row disappearing. + await expect(page.getByTestId(`test-run-row-${run.id}`)).toContainText( + "Completed", + ); await page.goto(`/workspaces/${workspace.id}/tests/runs/${run.id}`); await expect( @@ -564,7 +568,11 @@ test.describe("Test management browser lifecycle", () => { ); await expect(execution).toHaveAttribute("data-run-id", String(rerun.id)); await page.getByTestId("test-execution-back").click(); - await expect(page.getByTestId(`test-run-row-${run.id}`)).toHaveCount(0); + // The rerun is independent: the original run stays listed as Completed + // while the new run is in progress. + await expect(page.getByTestId(`test-run-row-${run.id}`)).toContainText( + "Completed", + ); await expect(page.getByTestId(`test-run-row-${rerun.id}`)).toBeVisible(); await page.goto(`/workspaces/${workspace.id}/tests/sets`); diff --git a/e2e/tests/wi-1804-agent-model-editor.spec.ts b/e2e/tests/wi-1804-agent-model-editor.spec.ts new file mode 100644 index 0000000..0461dfa --- /dev/null +++ b/e2e/tests/wi-1804-agent-model-editor.spec.ts @@ -0,0 +1,106 @@ +import { createWorkspaceViaAPI } from '../fixtures/api-helpers'; +import { expect, test } from '../fixtures/context-path'; +import { generateWorkspace } from '../fixtures/test-data'; + +// WI-1804: changing the model selection while a save is in flight must not be +// recorded as saved. The completion handler has to reconcile from the +// connection that was submitted (and persisted), not the mutable selection. +test.describe('Agent model editor', () => { + test('keeps a newer selection unsaved when it changes during save', async ({ page, request }) => { + const workspace = await createWorkspaceViaAPI( + request, + generateWorkspace(`agent-model-${Date.now()}`) + ); + const apiRoot = `**/api/workspaces/${workspace.id}`; + const agentId = 501; + + const profile = { + id: agentId, + name: 'Workspace Guide', + handle: 'workspace-guide', + profile_type: 'standard', + runtime: 'standard', + lifecycle: 'draft', + availability: 'draft', + available: false, + profile_version: 3, + llm_connection_id: 11, + model_summary: 'alpha-model', + instructions: 'Help members find the right workspace context.', + skill_ids: [], + capability_groups: [], + target_pool_id: null, + }; + + await page.route(`${apiRoot}/agent-profiles`, (route) => route.fulfill({ json: [profile] })); + await page.route(`${apiRoot}/agent-bindings`, (route) => route.fulfill({ json: [profile] })); + await page.route(`${apiRoot}/agent-skills`, (route) => route.fulfill({ json: [] })); + await page.route(`${apiRoot}/agent-tool-capabilities`, (route) => + route.fulfill({ json: [] }) + ); + await page.route(`**/workspaces/${workspace.id}/agent-runs**`, (route) => + route.fulfill({ json: [] }) + ); + await page.route(`${apiRoot}/action-capabilities**`, (route) => + route.fulfill({ json: [] }) + ); + await page.route(`${apiRoot}/agent-profiles/${agentId}/validation`, (route) => + route.fulfill({ json: { ready: true, errors: [] } }) + ); + await page.route('**/api/llm/connections', (route) => + route.fulfill({ + json: [ + { id: 11, name: 'Alpha', model: 'alpha-model' }, + { id: 12, name: 'Beta', model: 'beta-model' }, + { id: 13, name: 'Gamma', model: 'gamma-model' }, + ], + }) + ); + + // Hold the update response so the test can change the selection while the + // request is in flight, and record what the request actually submitted. + let releaseUpdate!: () => void; + const updateGate = new Promise((resolve) => { + releaseUpdate = resolve; + }); + let signalUpdateSeen!: () => void; + const updateSeen = new Promise((resolve) => { + signalUpdateSeen = resolve; + }); + let submittedConnectionId: number | null = null; + await page.route(`${apiRoot}/agent-bindings/${agentId}/agent-config`, async (route) => { + submittedConnectionId = route.request().postDataJSON()?.llm_connection_id ?? null; + signalUpdateSeen(); + await updateGate; + await route.fulfill({ + json: { ...profile, llm_connection_id: submittedConnectionId }, + }); + }); + + await page.goto(`/workspaces/${workspace.id}/agents/${agentId}`); + await expect(page.getByTestId('agent-model-summary')).toContainText('alpha-model'); + + await page.getByTestId('agent-tab-tools').click(); + await expect(page.getByTestId('agent-model')).toBeVisible(); + + // Select Beta and save. + await page.locator('#agent-model-connection').click(); + await page.locator('#agent-model-connection-option-12').click(); + await expect(page.getByTestId('agent-model-save')).toBeEnabled(); + await page.getByTestId('agent-model-save').click(); + await updateSeen; + expect(submittedConnectionId).toBe(12); + + // While the save is pending, switch to Gamma. + await page.locator('#agent-model-connection').click(); + await page.locator('#agent-model-connection-option-13').click(); + + releaseUpdate(); + + // The persisted model is Beta; Gamma stays unsaved. + await expect(page.getByTestId('agent-model-save')).toBeEnabled(); + await expect(page.getByTestId('agent-model-saved')).toHaveCount(0); + await page.getByTestId('agent-tab-overview').click(); + await expect(page.getByTestId('agent-model-summary')).toContainText('beta-model'); + }); +}); diff --git a/e2e/tests/workflow-designer.spec.ts b/e2e/tests/workflow-designer.spec.ts index 102f7d9..467fa6b 100644 --- a/e2e/tests/workflow-designer.spec.ts +++ b/e2e/tests/workflow-designer.spec.ts @@ -333,4 +333,72 @@ test.describe('Workflow designer UI round-trip', () => { timeout: 10_000, }); }); + + test('clicking a transition edge opens the inspector with its controls in view', async ({ + page, + request, + }) => { + test.setTimeout(90_000); + + const stamp = Date.now(); + const prefix = `wfd-inspector-${stamp}`; + const categoryId = await defaultCategoryId(request); + + const from = await createStatus(request, `${prefix}-From`, categoryId); + const to = await createStatus(request, `${prefix}-To`, categoryId); + const workflow = await createWorkflow(request, `${prefix}-wf`); + const transitions = await setTransitions(request, workflow.id, [ + { from_status_id: null, to_status_id: from.id }, + { from_status_id: from.id, to_status_id: to.id }, + ]); + const edge = transitions.find( + (transition) => transition.from_status_id === from.id && transition.to_status_id === to.id + ); + expect(edge, 'from -> to transition not created').toBeTruthy(); + + // Bind a screen through the API (setup), so the inspector renders its + // bound-state controls. The name is deliberately long: the picker's widest + // option drives its min-content width, which is what pushed the Unbind + // button off screen. + const screenName = `${prefix}-Support Ticket Resolution Screen With A Long Name`; + const screenResp = await request.post('/api/v2/screens', { + headers: SEC_FETCH, + data: { name: screenName, description: 'e2e transition screen' }, + }); + expect( + screenResp.ok(), + `create screen: ${screenResp.status()} ${await screenResp.text()}` + ).toBeTruthy(); + const screen = (await screenResp.json()).data; + const bindResp = await request.put(`/api/v2/transitions/${edge!.id}/screen`, { + headers: SEC_FETCH, + data: { screen_id: screen.id }, + }); + expect(bindResp.ok(), `bind screen: ${bindResp.status()} ${await bindResp.text()}`).toBeTruthy(); + + await page.goto(`/workflows/${workflow.id}/design`); + await expect(page.getByTestId('workflow-canvas')).toBeVisible({ timeout: 20_000 }); + + // A narrow laptop viewport: palette + canvas + inspector must still fit, or + // the inspector's right-hand controls get pushed off screen. + await page.setViewportSize({ width: 1024, height: 768 }); + + // The edge carries a stable test id derived from the persisted transition + // id. Dispatch the click so the assertion is about the inspector, not + // about hitting a one-pixel SVG stroke. + await page.getByTestId(`workflow-edge-${edge!.id}`).dispatchEvent('click'); + + await expect(page.getByTestId('transition-inspector')).toBeVisible(); + await expect(page.getByTestId('transition-inspector-screen-name')).toHaveText(screenName); + + // The canvas must not push the inspector's right-hand controls off the + // viewport (WI-1835 follow-up). + await expect(page.getByTestId('transition-inspector-unbind')).toBeInViewport(); + await expect(page.getByTestId('transition-inspector-create-screen')).toBeInViewport(); + + // The screen's fields are editable from the inspector, not just on the + // Screens admin page. + await page.getByTestId('transition-inspector-edit-fields').click(); + await expect(page.getByTestId('screen-field-layout-editor')).toBeVisible(); + }); }); diff --git a/e2e/tests/workspace-widget-resize.spec.ts b/e2e/tests/workspace-widget-resize.spec.ts index 6a2b688..0dde693 100644 --- a/e2e/tests/workspace-widget-resize.spec.ts +++ b/e2e/tests/workspace-widget-resize.spec.ts @@ -4,11 +4,11 @@ import { authenticateAdminRequest, createWorkspaceViaAPI } from '../fixtures/api const BASE_URL = process.env.BASE_URL || 'http://localhost:8080'; const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' }; -// The workspace overview widget resize handle used to be a transparent 6px -// strip that only rendered in customize mode. It is now always present, shows a -// grip on widget hover, and drags to a persisted column width. +// Widget resize controls belong to customize mode: the grip and the width menu +// must stay out of the resting dashboard and only appear once the user starts +// customizing. test.describe('workspace overview widget resizing', () => { - test('reveals the resize grip on hover and persists a dragged width', async ({ + test('reveals the resize controls only in customize mode and persists a dragged width', async ({ page, request, }) => { @@ -62,16 +62,17 @@ test.describe('workspace overview widget resizing', () => { await expect(widget).toBeVisible({ timeout: 15_000 }); await expect(widget).toHaveAttribute('data-widget-width', '1'); - const handle = widget.getByTestId('widget-resize-handle'); - const grip = widget.getByTestId('widget-resize-grip'); + // Resting state: no resize affordances. + await expect(widget.getByTestId('widget-resize-handle')).toHaveCount(0); + await expect(widget.getByTestId('widget-width-menu')).toHaveCount(0); - // Resting state: the handle is available but out of the tab order and - // the grip stays hidden until the widget is hovered. - await expect(handle).toHaveAttribute('tabindex', '-1'); - await expect(grip).toHaveCSS('opacity', '0'); + // Entering customize mode reveals the handle and the width menu. + await page.getByTestId('workspace-dashboard-customize').click(); - await widget.hover(); - await expect(grip).toHaveCSS('opacity', '1'); + const handle = widget.getByTestId('widget-resize-handle'); + await expect(handle).toBeVisible(); + await expect(widget.getByTestId('widget-width-menu')).toBeVisible(); + await expect(widget.getByTestId('widget-resize-grip')).toBeVisible(); // Drag past the widget's max width; it must clamp to the registry max. const box = await handle.boundingBox(); @@ -91,12 +92,11 @@ test.describe('workspace overview widget resizing', () => { await expect(widget).toHaveAttribute('data-widget-width', '3'); - // The width survives a reload. + // The width survives a reload and the controls are hidden again. await page.reload(); - await expect(page.getByTestId('widget-w-stats')).toHaveAttribute( - 'data-widget-width', - '3', - { timeout: 15_000 }, - ); + const reloaded = page.getByTestId('widget-w-stats'); + await expect(reloaded).toHaveAttribute('data-widget-width', '3', { timeout: 15_000 }); + await expect(reloaded.getByTestId('widget-resize-handle')).toHaveCount(0); + await expect(reloaded.getByTestId('widget-width-menu')).toHaveCount(0); }); }); diff --git a/frontend/scripts/check-silent-errors.spec.js b/frontend/scripts/check-silent-errors.spec.js new file mode 100644 index 0000000..4a643b1 --- /dev/null +++ b/frontend/scripts/check-silent-errors.spec.js @@ -0,0 +1,107 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { countSilentScopes, findRegressions } from './check-silent-errors.js'; + +let dir; + +async function fixture(files) { + dir = await mkdtemp(path.join(tmpdir(), 'silent-errors-')); + const paths = []; + for (const [name, source] of Object.entries(files)) { + const full = path.join(dir, name); + await writeFile(full, source); + paths.push(full); + } + return paths; +} + +afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + dir = undefined; +}); + +describe('countSilentScopes', () => { + it('flags a catch that only logs', async () => { + const files = await fixture({ + 'a.js': `export function load() { + try { doThing(); } catch (error) { console.error('Failed to load:', error); } + }`, + }); + + const counts = await countSilentScopes(files, dir); + + expect(counts.get('a.js')).toBe(1); + }); + + it('accepts a catch that surfaces through surfaceError', async () => { + const files = await fixture({ + 'b.js': `import { surfaceError } from './toasts.js'; + export function save() { + try { doThing(); } catch (error) { surfaceError(error, { context: 'save' }); } + }`, + }); + + expect(await countSilentScopes(files, dir)).toEqual(new Map()); + }); + + it('accepts a catch that rethrows', async () => { + const files = await fixture({ + 'c.js': `export function save() { + try { doThing(); } catch (error) { console.error('Failed to save:', error); throw error; } + }`, + }); + + expect(await countSilentScopes(files, dir)).toEqual(new Map()); + }); + + it('accepts a catch that sets an inline error state', async () => { + const files = await fixture({ + 'd.js': `export function save() { + try { doThing(); } catch (error) { console.error('Failed to save:', error); error = error.message; } + }`, + }); + + expect(await countSilentScopes(files, dir)).toEqual(new Map()); + }); + + it('resolves a local helper that surfaces a toast', async () => { + const files = await fixture({ + 'e.js': `import { errorToast } from './toasts.js'; + function handleError(error) { errorToast(error.message); } + export function save() { + try { doThing(); } catch (error) { console.error('Failed to save:', error); handleError(error); } + }`, + }); + + expect(await countSilentScopes(files, dir)).toEqual(new Map()); + }); + + it('flags a promise .catch callback that only logs', async () => { + const files = await fixture({ + 'f.js': `export function save() { + doThing().catch((error) => console.error('Failed to save:', error)); + }`, + }); + + expect((await countSilentScopes(files, dir)).get('f.js')).toBe(1); + }); +}); + +describe('findRegressions', () => { + it('flags new files and files above their baseline', () => { + const counts = new Map([ + ['known.js', 3], + ['grew.js', 2], + ['new.js', 1], + ]); + + const regressions = findRegressions(counts, { 'known.js': 3, 'grew.js': 1 }); + + expect(regressions).toEqual([ + { file: 'grew.js', count: 2, allowed: 1 }, + { file: 'new.js', count: 1, allowed: 0 }, + ]); + }); +}); diff --git a/frontend/scripts/locale-key-quoting.test.js b/frontend/scripts/locale-key-quoting.test.js new file mode 100644 index 0000000..03ee4bb --- /dev/null +++ b/frontend/scripts/locale-key-quoting.test.js @@ -0,0 +1,97 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { findMixedKeyQuoting } from './locale-key-quoting.js'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const localeRoot = path.resolve(scriptDir, '../src/lib/locales'); + +function source(content, file = 'src/lib/locales/xx/supplemental.js') { + return { file, content }; +} + +async function readLocaleSources() { + const entries = await readdir(localeRoot, { withFileTypes: true }); + const files = []; + + for (const entry of entries.filter((e) => e.isDirectory())) { + const dir = path.join(localeRoot, entry.name); + const names = (await readdir(dir)).filter((f) => f.endsWith('.js') && f !== 'index.js'); + for (const name of names) { + files.push({ + file: `src/lib/locales/${entry.name}/${name}`, + content: await readFile(path.join(dir, name), 'utf-8'), + }); + } + } + + return files; +} + +describe('findMixedKeyQuoting', () => { + it('flags a bare identifier key in a file that quotes identifier keys', () => { + const content = [ + 'export default {', + ' "title": "Sprache",', + ' languageTitle: "Sprache",', + ' "saved": "Gespeichert",', + '};', + ].join('\n'); + + expect(findMixedKeyQuoting([source(content)])).toEqual([ + { + file: 'src/lib/locales/xx/supplemental.js', + unquoted: [{ line: 3, key: 'languageTitle' }], + }, + ]); + }); + + it('accepts a file whose identifier keys are all quoted', () => { + const content = ['export default {', ' "title": "Sprache",', ' "saved": "Gespeichert",', '};'].join( + '\n' + ); + + expect(findMixedKeyQuoting([source(content)])).toEqual([]); + }); + + it('accepts an unquoted-key file that only quotes keys that require it', () => { + const content = [ + 'export default {', + ' title: "Sprache",', + ' saved: "Gespeichert",', + ' "field-mappings": "Zuordnungen",', + '};', + ].join('\n'); + + expect(findMixedKeyQuoting([source(content)])).toEqual([]); + }); + + it('reports every offending line across every file', () => { + const first = source( + ['export default {', ' "a": "x",', ' badOne: "y",', ' badTwo: "z",', '};'].join('\n'), + 'src/lib/locales/aa/a.js' + ); + const second = source( + ['export default {', ' "b": "x",', ' badThree: "y",', '};'].join('\n'), + 'src/lib/locales/bb/b.js' + ); + + expect(findMixedKeyQuoting([first, second])).toEqual([ + { + file: 'src/lib/locales/aa/a.js', + unquoted: [ + { line: 3, key: 'badOne' }, + { line: 4, key: 'badTwo' }, + ], + }, + { file: 'src/lib/locales/bb/b.js', unquoted: [{ line: 3, key: 'badThree' }] }, + ]); + }); +}); + +describe('shipped locale catalogs', () => { + it('never mix quoted and unquoted identifier keys', async () => { + expect(findMixedKeyQuoting(await readLocaleSources())).toEqual([]); + }); +}); diff --git a/frontend/src/lib/api/core.test.js b/frontend/src/lib/api/core.test.js index 30d451d..78d49bb 100644 --- a/frontend/src/lib/api/core.test.js +++ b/frontend/src/lib/api/core.test.js @@ -140,6 +140,43 @@ describe('fetchAPI — error mapping', () => { expect(global.fetch).toHaveBeenCalledWith('/api/v2/users?page=0', expect.any(Object)); }); + test.each([ + ['v1 uppercase code', { error: 'Request was canceled', code: 'REQUEST_CANCELED' }], + [ + 'v2 lowercase code', + { error: { code: 'request_canceled', message: 'Report request was canceled' } }, + ], + ])('maps a %s canceled request to an AbortError', async (_label, body) => { + global.fetch = vi.fn(() => + Promise.resolve( + makeResponse({ + status: 408, + statusText: 'Request Timeout', + body: JSON.stringify(body), + }) + ) + ); + + // The backend answers a canceled request context with 408 REQUEST_CANCELED. + // Callers already treat an AbortError as expected control flow, so a closed + // modal or navigation must not surface a failure toast. + await expect(fetchAPI('/worklogs/report')).rejects.toMatchObject({ name: 'AbortError' }); + }); + + test('keeps a 408 without the canceled code as a surfaced error', async () => { + global.fetch = vi.fn(() => + Promise.resolve( + makeResponse({ + status: 408, + statusText: 'Request Timeout', + body: JSON.stringify({ error: 'Server timed out', code: 'REQUEST_TIMEOUT' }), + }) + ) + ); + + await expect(fetchAPI('/x')).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT' }); + }); + test("falls back to 'message' field when 'error' is missing", async () => { global.fetch = vi.fn(() => Promise.resolve( diff --git a/frontend/src/lib/commands/buildCommands.test.js b/frontend/src/lib/commands/buildCommands.test.js index e66a6f3..8256ae3 100644 --- a/frontend/src/lib/commands/buildCommands.test.js +++ b/frontend/src/lib/commands/buildCommands.test.js @@ -95,7 +95,6 @@ describe('integration: live providers', () => { const withPerms = globalNavigationProvider({ t: (k) => k, permissions: { - canAccessLogbook: true, canAccessAssets: true, canAccessPortalHub: true, canAccessCustomers: true, diff --git a/frontend/src/lib/components/Button.test.js b/frontend/src/lib/components/Button.test.js index 89f376a..610607c 100644 --- a/frontend/src/lib/components/Button.test.js +++ b/frontend/src/lib/components/Button.test.js @@ -45,3 +45,14 @@ describe('Button toggle state', () => { expect(screen.getByTestId('plain-button')).not.toHaveAttribute('aria-pressed'); }); }); + +describe('Button variant hover tokens', () => { + it('hovers the context variant with the context hover surface, not the neutral ink', () => { + render(Button, { props: { variant: 'context', dataTestid: 'context-button' } }); + + const className = screen.getByTestId('context-button').className; + expect(className).toContain('hover:bg-[var(--ctx-hover-bg,var(--ds-surface-hovered))]'); + expect(className).not.toContain('--ds-background-neutral-hovered'); + expect(className).not.toContain('--ctx-active-bg'); + }); +}); diff --git a/frontend/src/lib/components/DataTable.test.js b/frontend/src/lib/components/DataTable.test.js index 0e899ef..d871906 100644 --- a/frontend/src/lib/components/DataTable.test.js +++ b/frontend/src/lib/components/DataTable.test.js @@ -386,4 +386,23 @@ describe('DataTable', () => { expect(headerCells[1].classList.contains('text-right')).toBe(true); }); }); + + describe('bidi direction', () => { + it('defaults cells to logical start alignment and content direction', () => { + render(DataTable, { + props: { + columns: sampleColumns, + data: sampleData, + }, + }); + + const header = document.querySelector('thead th'); + expect(header.classList.contains('text-start')).toBe(true); + expect(header.classList.contains('text-left')).toBe(false); + + const cell = document.querySelector('tbody td span[dir="auto"]'); + expect(cell).not.toBeNull(); + expect(cell.textContent).toContain('Alice'); + }); + }); }); diff --git a/frontend/src/lib/components/LogoUploader.test.js b/frontend/src/lib/components/LogoUploader.test.js new file mode 100644 index 0000000..7e3ae66 --- /dev/null +++ b/frontend/src/lib/components/LogoUploader.test.js @@ -0,0 +1,35 @@ +import { render } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../stores/attachmentStatus.svelte.js', () => ({ + attachmentStatus: { enabled: true }, +})); + +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key, fallback) => fallback ?? key, +})); + +import LogoUploader from './LogoUploader.svelte'; + +// SVG is script-capable and only theme logos accept it server-side, so the +// file picker must exclude it unless the caller opts in. +describe('LogoUploader accept contract', () => { + it('restricts the file picker to raster images by default', () => { + const { container } = render(LogoUploader); + + const input = container.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'image/png,image/jpeg,image/gif,image/webp'); + expect(input.getAttribute('accept')).not.toContain('svg'); + }); + + it('admits SVG only when the caller opts in', () => { + const { container } = render(LogoUploader, { + accept: 'image/png,image/jpeg,image/svg+xml,image/gif,image/webp', + }); + + expect(container.querySelector('input[type="file"]')).toHaveAttribute( + 'accept', + 'image/png,image/jpeg,image/svg+xml,image/gif,image/webp' + ); + }); +}); diff --git a/frontend/src/lib/components/TextField.test.js b/frontend/src/lib/components/TextField.test.js new file mode 100644 index 0000000..0dc04a5 --- /dev/null +++ b/frontend/src/lib/components/TextField.test.js @@ -0,0 +1,40 @@ +import { cleanup, render, screen } from '@testing-library/svelte'; +import { afterEach, expect, test, vi } from 'vitest'; +import { createRawSnippet } from 'svelte'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import TextField from './TextField.svelte'; + +afterEach(cleanup); + +test('renders the label and the bound input value', () => { + render(TextField, { props: { label: 'Name', id: 'name', value: 'Ada' } }); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText(/Name/)).toHaveValue('Ada'); +}); + +test('marks the field required without requiring the input', () => { + render(TextField, { props: { label: 'Name', id: 'name', requiredMark: true } }); + + expect(screen.getByText(/Name/).textContent).toContain('*'); + expect(screen.getByLabelText(/Name/)).not.toBeRequired(); +}); + +test('sets the required attribute when required', () => { + render(TextField, { props: { label: 'Name', id: 'name', required: true } }); + + expect(screen.getByLabelText(/Name/)).toBeRequired(); +}); + +test('renders rich label content through labelSnippet', () => { + const labelSnippet = createRawSnippet(() => ({ + render: () => 'Rich', + })); + + render(TextField, { props: { labelSnippet, id: 'name' } }); + + expect(screen.getByTestId('rich-label')).toBeInTheDocument(); + expect(screen.getByLabelText(/Rich/)).toBeInTheDocument(); +}); diff --git a/frontend/src/lib/components/bidi.test.js b/frontend/src/lib/components/bidi.test.js new file mode 100644 index 0000000..28618b4 --- /dev/null +++ b/frontend/src/lib/components/bidi.test.js @@ -0,0 +1,62 @@ +import { render } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import Badge from './Badge.svelte'; +import Chip from './Chip.svelte'; +import ItemKey from './ItemKey.svelte'; +import Lozenge from './Lozenge.svelte'; +import NativeSelect from './NativeSelect.svelte'; +import Text from './Text.svelte'; +import TruncatedFieldValue from './TruncatedFieldValue.svelte'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('bidi direction on shared text primitives', () => { + it('Text follows its content direction unless explicitly aligned', () => { + const { container } = render(Text, { props: { as: 'p', children: () => 'Hello' } }); + expect(container.querySelector('p')).toHaveAttribute('dir', 'auto'); + + const aligned = render(Text, { + props: { as: 'p', align: 'center', children: () => 'Centered' }, + }); + expect(aligned.container.querySelector('p')).not.toHaveAttribute('dir'); + }); + + it('Badge, Chip, and Lozenge follow their content direction', () => { + const badge = render(Badge, { props: { children: () => 'Active' } }); + expect(badge.container.firstElementChild).toHaveAttribute('dir', 'auto'); + + const chip = render(Chip, { props: { children: () => 'Frontend' } }); + expect(chip.container.firstElementChild).toHaveAttribute('dir', 'auto'); + + const lozenge = render(Lozenge, { props: { text: 'Tag' } }); + expect(lozenge.container.firstElementChild).toHaveAttribute('dir', 'auto'); + }); + + it('ItemKey stays left-to-right inside an RTL interface', () => { + const { container } = render(ItemKey, { + props: { itemKey: 'WI-123', clickable: false }, + }); + expect(container.firstElementChild).toHaveAttribute('dir', 'ltr'); + }); + + it('NativeSelect follows the selected option direction', () => { + const { container } = render(NativeSelect, { + props: { value: 'a', options: [{ value: 'a', label: 'Option' }] }, + }); + expect(container.querySelector('select')).toHaveAttribute('dir', 'auto'); + }); + + it('TruncatedFieldValue keeps end alignment while following content', () => { + const { container } = render(TruncatedFieldValue, { + props: { value: 'ØšŲ†ŲˆØ§Ų†', testId: 'tv' }, + }); + const value = container.querySelector('[data-testid="tv"]'); + expect(value).toHaveAttribute('dir', 'auto'); + expect(value.getAttribute('style')).toContain('text-align: end'); + }); +}); diff --git a/frontend/src/lib/composables/CollectionHierarchyHarness.svelte b/frontend/src/lib/composables/CollectionHierarchyHarness.svelte new file mode 100644 index 0000000..ffe6aff --- /dev/null +++ b/frontend/src/lib/composables/CollectionHierarchyHarness.svelte @@ -0,0 +1,21 @@ + diff --git a/frontend/src/lib/composables/ItemAttachmentsHarness.svelte b/frontend/src/lib/composables/ItemAttachmentsHarness.svelte index 0a6ac07..3c8928f 100644 --- a/frontend/src/lib/composables/ItemAttachmentsHarness.svelte +++ b/frontend/src/lib/composables/ItemAttachmentsHarness.svelte @@ -1,8 +1,10 @@ + +
diff --git a/frontend/src/lib/dialogs/TransitionScreenModal.test.js b/frontend/src/lib/dialogs/TransitionScreenModal.test.js new file mode 100644 index 0000000..4f1edb1 --- /dev/null +++ b/frontend/src/lib/dialogs/TransitionScreenModal.test.js @@ -0,0 +1,202 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { getFields, getAllCustomFields } = vi.hoisted(() => ({ + getFields: vi.fn(), + getAllCustomFields: vi.fn(), +})); + +vi.mock('../api/configuration.js', () => ({ + screens: { getFields }, + customFields: { getAll: getAllCustomFields }, +})); + +vi.mock('../editors/LazyMilkdownEditor.svelte', async () => ({ + default: (await import('./TransitionDescriptionStub.svelte')).default, +})); + +// The real store loads translations asynchronously; a synchronous map keeps +// the label assertions deterministic and makes the `items.title` regression +// observable (it resolves to "Items" rather than "Title"). +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key, params) => { + const messages = { + 'common.title': 'Title', + 'common.description': 'Description', + 'items.title': 'Items', + 'common.loading': 'Loading', + 'transitionScreen.title': 'Transition', + 'transitionScreen.moveTo': 'Move to {status}', + 'transitionScreen.comment': 'Comment', + 'transitionScreen.commentPlaceholder': 'Add a comment (optional)', + 'transitionScreen.confirm': 'Confirm', + 'transitionScreen.failed': 'Transition failed', + 'transitionScreen.missingFields': 'Missing required fields', + 'dialogs.cancel': 'Cancel', + 'dialogs.confirm': 'Confirm', + }; + let value = messages[key] ?? key; + if (params) { + for (const [name, replacement] of Object.entries(params)) { + value = value.replace(`{${name}}`, String(replacement)); + } + } + return value; + }, +})); + +import TransitionScreenModal from './TransitionScreenModal.svelte'; + +const screenSummary = { id: 7, name: 'Close Screen', field_count: 2, required_field_count: 1 }; + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + getFields.mockReset(); + getAllCustomFields.mockReset(); +}); + +describe('TransitionScreenModal', () => { + it('labels the title field and pre-fills displayed fields from the item', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + { field_type: 'default', field_identifier: 'description', is_required: false }, + ]); + getAllCustomFields.mockResolvedValue([]); + + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title', description: 'Existing description' }, + }, + }); + + const titleInput = await screen.findByTestId('transition-field-title'); + expect(titleInput).toHaveValue('Existing title'); + expect(screen.getByTestId('transition-field-description')).toHaveAttribute( + 'data-content', + 'Existing description' + ); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.queryByText('Items')).not.toBeInTheDocument(); + }); + + it('submits the seeded values with the transition', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + { field_type: 'default', field_identifier: 'description', is_required: false }, + ]); + getAllCustomFields.mockResolvedValue([]); + + const onconfirm = vi.fn().mockResolvedValue(undefined); + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title', description: 'Existing description' }, + onconfirm, + }, + }); + + await screen.findByTestId('transition-field-title'); + await fireEvent.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => + expect(onconfirm).toHaveBeenCalledWith({ + title: 'Existing title', + description: 'Existing description', + }) + ); + }); + + it('submits with the modal submit shortcut', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + ]); + getAllCustomFields.mockResolvedValue([]); + + const onconfirm = vi.fn().mockResolvedValue(undefined); + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title' }, + onconfirm, + }, + }); + + const titleInput = await screen.findByTestId('transition-field-title'); + await fireEvent.keyDown(titleInput, { key: 'Enter', ctrlKey: true }); + + await waitFor(() => + expect(onconfirm).toHaveBeenCalledWith({ title: 'Existing title' }) + ); + }); + + it('submits with Enter from a single-line field', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + ]); + getAllCustomFields.mockResolvedValue([]); + + const onconfirm = vi.fn().mockResolvedValue(undefined); + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title' }, + onconfirm, + }, + }); + + const titleInput = await screen.findByTestId('transition-field-title'); + await fireEvent.keyDown(titleInput, { key: 'Enter' }); + + await waitFor(() => + expect(onconfirm).toHaveBeenCalledWith({ title: 'Existing title' }) + ); + }); + + it('cancels with Escape', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + ]); + getAllCustomFields.mockResolvedValue([]); + + const oncancel = vi.fn(); + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title' }, + oncancel, + }, + }); + + const titleInput = await screen.findByTestId('transition-field-title'); + await fireEvent.keyDown(titleInput, { key: 'Escape' }); + + await waitFor(() => expect(oncancel).toHaveBeenCalled()); + }); + + it('portals the backdrop to the document body so a portaled dialog cannot cover it', async () => { + getFields.mockResolvedValue([ + { field_type: 'default', field_identifier: 'title', is_required: true }, + ]); + getAllCustomFields.mockResolvedValue([]); + + render(TransitionScreenModal, { + props: { + show: true, + screen: screenSummary, + item: { id: 1, title: 'Existing title' }, + }, + }); + + const modal = await screen.findByTestId('transition-screen-modal'); + const backdrop = modal.closest('[role="dialog"]'); + expect(backdrop).not.toBeNull(); + expect(backdrop.parentElement).toBe(document.body); + }); +}); diff --git a/frontend/src/lib/editors/EditableField.test.js b/frontend/src/lib/editors/EditableField.test.js new file mode 100644 index 0000000..2efbdc6 --- /dev/null +++ b/frontend/src/lib/editors/EditableField.test.js @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import Harness from './EditableFieldHarness.svelte'; + +afterEach(cleanup); + +function renderField(props = {}) { + const oncommit = vi.fn().mockResolvedValue(undefined); + render(Harness, { props: { value: 'original', oncommit, ...props } }); + return { oncommit }; +} + +async function openEditor() { + await fireEvent.click(screen.getByTestId('display')); + return screen.getByTestId('editor'); +} + +describe('EditableField', () => { + it('renders the display and opens the editor on click', async () => { + renderField(); + + expect(screen.getByTestId('display')).toHaveTextContent('original'); + const editor = await openEditor(); + expect(editor).toHaveValue('original'); + }); + + it('commits the draft on blur', async () => { + const { oncommit } = renderField(); + const editor = await openEditor(); + + await fireEvent.input(editor, { target: { value: 'changed' } }); + await fireEvent.blur(editor); + + expect(oncommit).toHaveBeenCalledWith('changed'); + expect(screen.queryByTestId('editor')).not.toBeInTheDocument(); + }); + + it('commits the draft on Enter', async () => { + const { oncommit } = renderField(); + const editor = await openEditor(); + + await fireEvent.input(editor, { target: { value: 'changed' } }); + await fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(oncommit).toHaveBeenCalledWith('changed'); + }); + + it('cancels on Escape without committing', async () => { + const { oncommit } = renderField(); + const editor = await openEditor(); + + await fireEvent.input(editor, { target: { value: 'changed' } }); + await fireEvent.keyDown(editor, { key: 'Escape' }); + + expect(oncommit).not.toHaveBeenCalled(); + expect(screen.queryByTestId('editor')).not.toBeInTheDocument(); + }); + + it('does not commit an unchanged value', async () => { + const { oncommit } = renderField(); + const editor = await openEditor(); + + await fireEvent.blur(editor); + + expect(oncommit).not.toHaveBeenCalled(); + expect(screen.queryByTestId('editor')).not.toBeInTheDocument(); + }); + + it('keeps the editor open with the error when the commit rejects', async () => { + const oncommit = vi.fn().mockRejectedValue(new Error('nope')); + render(Harness, { props: { value: 'original', oncommit } }); + const editor = await openEditor(); + + await fireEvent.input(editor, { target: { value: 'changed' } }); + await fireEvent.blur(editor); + + expect(screen.getByTestId('editor')).toBeInTheDocument(); + expect(screen.getByTestId('error')).toHaveTextContent('nope'); + }); + + it('does not open the editor when not editable', async () => { + renderField({ editable: false }); + + await fireEvent.click(screen.getByTestId('display')); + + expect(screen.queryByTestId('editor')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/editors/EditableFieldHarness.svelte b/frontend/src/lib/editors/EditableFieldHarness.svelte new file mode 100644 index 0000000..7a2ab48 --- /dev/null +++ b/frontend/src/lib/editors/EditableFieldHarness.svelte @@ -0,0 +1,19 @@ + + + + {#snippet display()} + {value} + {/snippet} + {#snippet editor({ draft, setDraft, error })} + setDraft(e.currentTarget.value)} + /> + {#if error}{error}{/if} + {/snippet} + diff --git a/frontend/src/lib/editors/InlineEditorActions.test.js b/frontend/src/lib/editors/InlineEditorActions.test.js new file mode 100644 index 0000000..c414fb0 --- /dev/null +++ b/frontend/src/lib/editors/InlineEditorActions.test.js @@ -0,0 +1,28 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, expect, test, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import InlineEditorActions from './InlineEditorActions.svelte'; + +afterEach(cleanup); + +test('fires the save and cancel callbacks', async () => { + const onsave = vi.fn(); + const oncancel = vi.fn(); + + render(InlineEditorActions, { props: { onsave, oncancel } }); + + await fireEvent.click(screen.getByTitle('editors.saveEnter')); + await fireEvent.click(screen.getByTitle('editors.cancelEscape')); + + expect(onsave).toHaveBeenCalledTimes(1); + expect(oncancel).toHaveBeenCalledTimes(1); +}); + +test('replaces the buttons with a spinner while saving', () => { + render(InlineEditorActions, { props: { saving: true, onsave: vi.fn(), oncancel: vi.fn() } }); + + expect(screen.queryByTitle('editors.saveEnter')).toBeNull(); + expect(screen.queryByTitle('editors.cancelEscape')).toBeNull(); +}); diff --git a/frontend/src/lib/editors/MilkdownEditor.test.js b/frontend/src/lib/editors/MilkdownEditor.test.js index 819be9f..055c123 100644 --- a/frontend/src/lib/editors/MilkdownEditor.test.js +++ b/frontend/src/lib/editors/MilkdownEditor.test.js @@ -86,6 +86,7 @@ vi.mock("../stores/attachmentStatus.svelte.js", () => ({ vi.mock("../router.js", () => ({ navigate: vi.fn() })); vi.mock("./milkdown-mention-mark.js", () => ({ mentionDecorationPlugin: {} })); vi.mock("./milkdown-link-sanitizer.js", () => ({ linkSanitizerPlugin: {} })); +vi.mock("./milkdown-image-blocker.js", () => ({ imageBlockerPlugin: {} })); vi.mock("./milkdown-hardbreak.js", () => ({ rewriteBreakHTML: {} })); vi.mock("./milkdown-excalidraw-block.svelte.js", () => ({ excalidrawBlock: {} })); vi.mock("./code-highlight.js", () => ({ highlightCodeBlocks: () => {} })); diff --git a/frontend/src/lib/editors/mentionResolver.test.js b/frontend/src/lib/editors/mentionResolver.test.js index 4bd98f5..54e245a 100644 --- a/frontend/src/lib/editors/mentionResolver.test.js +++ b/frontend/src/lib/editors/mentionResolver.test.js @@ -41,31 +41,28 @@ describe('resolveMentionUser', () => { }); describe('mentionTextFor', () => { - it('keeps a multi-word display name when it is unique', () => { - expect(mentionTextFor(alice, roster)).toBe('@"Alice Smith"'); + it('embeds the user id and display name', () => { + expect(mentionTextFor(alice)).toBe('@{Alice Smith#3}'); }); - it('uses the username for a single-word display name', () => { - expect(mentionTextFor(cher, roster)).toBe('@cher'); + it('embeds the id for a single-word display name', () => { + expect(mentionTextFor(cher)).toBe('@{Cher#4}'); }); - it('falls back to the username when another user shares the display name', () => { - expect(mentionTextFor(johnA, roster)).toBe('@jdoe'); - expect(mentionTextFor(johnB, roster)).toBe('@john.doe'); + it('embeds the id even when another user shares the display name', () => { + expect(mentionTextFor(johnA)).toBe('@{John Doe#1}'); + expect(mentionTextFor(johnB)).toBe('@{John Doe#2}'); }); - it('does not treat the selected user as their own duplicate', () => { - expect(mentionTextFor(alice, [alice])).toBe('@"Alice Smith"'); + it('falls back to the username when the display name is empty', () => { + expect(mentionTextFor({ id: 9, username: 'ghost' })).toBe('@{ghost#9}'); }); - it('detects a duplicate even when roster entries carry no id', () => { - const a = { username: 'jdoe', first_name: 'John', last_name: 'Doe' }; - const b = { username: 'john.doe', first_name: 'John', last_name: 'Doe' }; - - expect(mentionTextFor(a, [a, b])).toBe('@jdoe'); - }); - - it('uses the username when the display name is empty', () => { - expect(mentionTextFor({ id: 9, username: 'ghost' }, roster)).toBe('@ghost'); + it('falls back to the legacy name syntax when there is no id', () => { + expect(mentionTextFor({ username: 'jdoe', first_name: 'John', last_name: 'Doe' })).toBe( + '@"John Doe"' + ); + expect(mentionTextFor({ username: 'cher' })).toBe('@cher'); }); }); + diff --git a/frontend/src/lib/features/assets/AttachmentDiagramList.test.js b/frontend/src/lib/features/assets/AttachmentDiagramList.test.js index 3de3f48..b4c3ac0 100644 --- a/frontend/src/lib/features/assets/AttachmentDiagramList.test.js +++ b/frontend/src/lib/features/assets/AttachmentDiagramList.test.js @@ -10,6 +10,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); @@ -36,6 +37,52 @@ describe('AttachmentDiagramList diagrams', () => { }); }); +describe('AttachmentDiagramList visibility toggle', () => { + const attachment = { + id: 11, + mime_type: 'image/png', + original_filename: 'evidence.png', + file_size: 2048, + has_thumbnail: false, + is_internal: false, + }; + + test('is absent unless the viewer can toggle visibility', () => { + render(AttachmentDiagramList, { attachments: [attachment] }); + + expect(screen.queryByTestId('item-attachment-visibility-11')).not.toBeInTheDocument(); + }); + + test('invokes the toggle handler with the attachment', async () => { + const ontogglevisibility = vi.fn(); + render(AttachmentDiagramList, { + attachments: [attachment], + canToggleVisibility: true, + ontogglevisibility, + }); + + const toggle = screen.getByTestId('item-attachment-visibility-11'); + expect(toggle).toHaveAttribute('aria-label', 'assets.hideFromCustomer'); + + await fireEvent.click(toggle); + + expect(ontogglevisibility).toHaveBeenCalledWith(attachment); + }); + + test('labels an internal attachment as hidden from the customer', () => { + render(AttachmentDiagramList, { + attachments: [{ ...attachment, id: 12, is_internal: true }], + canToggleVisibility: true, + ontogglevisibility: vi.fn(), + }); + + expect(screen.getByTestId('item-attachment-visibility-12')).toHaveAttribute( + 'aria-label', + 'assets.showToCustomer' + ); + }); +}); + describe('AttachmentDiagramList lightbox', () => { test('renders PDFs from the content endpoint and downloads from the download endpoint', async () => { const fetchMock = vi.fn().mockResolvedValue({ @@ -76,4 +123,43 @@ describe('AttachmentDiagramList lightbox', () => { expect(getContentUrl).toHaveBeenCalledWith(42); expect(getDownloadUrl).toHaveBeenCalledWith(42); }); + + test('closes on Escape and on a backdrop click but not on an inside click', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' })), + }) + ); + + render(AttachmentDiagramList, { + attachments: [ + { + id: 7, + mime_type: 'image/png', + original_filename: 'shot.png', + file_size: 10, + has_thumbnail: false, + }, + ], + }); + + await fireEvent.click(screen.getByText('shot.png')); + const dialog = await screen.findByRole('dialog'); + + // A click on the content does not dismiss the lightbox. + await fireEvent.click(document.querySelector('img[alt="shot.png"]')); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // A click on the backdrop does. + await fireEvent.click(dialog); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + // Escape closes it too. + await fireEvent.click(screen.getByText('shot.png')); + const reopened = await screen.findByRole('dialog'); + await fireEvent.keyDown(reopened, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); }); diff --git a/frontend/src/lib/features/channels/ChannelEmailConfig.test.js b/frontend/src/lib/features/channels/ChannelEmailConfig.test.js index 3eb4020..58061f4 100644 --- a/frontend/src/lib/features/channels/ChannelEmailConfig.test.js +++ b/frontend/src/lib/features/channels/ChannelEmailConfig.test.js @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); vi.mock('../../stores/permissions.svelte.js', () => ({ isSystemAdmin: readable(true) })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: mocks.errorToast, successToast: vi.fn(), })); @@ -45,6 +46,29 @@ function basicFormData(overrides = {}) { }; } +function oauthFormData(overrides = {}) { + return { + auth_method: 'oauth', + oauth_provider_type: 'microsoft', + oauth_client_id: 'client-id', + oauth_client_secret: '', + oauth_client_secret_stored: false, + stored_oauth_provider_type: '', + stored_oauth_client_id: '', + oauth_tenant_id: 'common', + oauth_connected: false, + oauth_email: '', + oauth_mailbox: 'support@example.com', + connected_oauth_provider_type: '', + connected_oauth_client_id: '', + connected_oauth_tenant_id: '', + connected_oauth_mailbox: '', + processing_disposition: 'mark_read', + enabled: false, + ...overrides, + }; +} + describe('ChannelEmailConfig mailbox disposition', () => { beforeEach(() => { vi.clearAllMocks(); @@ -71,3 +95,65 @@ describe('ChannelEmailConfig mailbox disposition', () => { expect(component.getConfig().email_processing_disposition).toBe('leave'); }); }); + +describe('ChannelEmailConfig OAuth client secret retention', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.intakesList.mockResolvedValue([]); + mocks.getItemTypes.mockResolvedValue([]); + }); + + it('accepts a stored secret without requiring re-entry', () => { + const { component } = render(ChannelEmailConfig, { + props: { + channelId: 7, + formData: oauthFormData({ + oauth_client_secret_stored: true, + stored_oauth_provider_type: 'microsoft', + stored_oauth_client_id: 'client-id', + }), + }, + }); + + expect(component.validate()).toEqual({ valid: true }); + }); + + it('requires a client secret when none is stored', () => { + const { component } = render(ChannelEmailConfig, { + props: { channelId: 7, formData: oauthFormData() }, + }); + + expect(component.validate()).toEqual({ valid: false, message: 'channel.clientSecretRequired' }); + }); + + it('requires a client secret again when the client identity changed', () => { + const { component } = render(ChannelEmailConfig, { + props: { + channelId: 7, + formData: oauthFormData({ + oauth_client_secret_stored: true, + stored_oauth_provider_type: 'microsoft', + stored_oauth_client_id: 'previous-client-id', + }), + }, + }); + + expect(component.validate()).toEqual({ valid: false, message: 'channel.clientSecretRequired' }); + }); + + it('records a just-saved secret and clears the field', () => { + const formData = oauthFormData({ + oauth_client_secret: 'typed-secret', + stored_oauth_provider_type: 'microsoft', + stored_oauth_client_id: 'client-id', + }); + const { component } = render(ChannelEmailConfig, { + props: { channelId: 7, formData }, + }); + + component.clearSecrets(); + + expect(formData.oauth_client_secret).toBe(''); + expect(formData.oauth_client_secret_stored).toBe(true); + }); +}); diff --git a/frontend/src/lib/features/channels/ChannelIntakesSection.test.js b/frontend/src/lib/features/channels/ChannelIntakesSection.test.js index 13f1990..88382a2 100644 --- a/frontend/src/lib/features/channels/ChannelIntakesSection.test.js +++ b/frontend/src/lib/features/channels/ChannelIntakesSection.test.js @@ -16,6 +16,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: mocks.errorToast, successToast: mocks.successToast, })); diff --git a/frontend/src/lib/features/channels/channelAdmin.test.js b/frontend/src/lib/features/channels/channelAdmin.test.js index 2e814ef..6d68878 100644 --- a/frontend/src/lib/features/channels/channelAdmin.test.js +++ b/frontend/src/lib/features/channels/channelAdmin.test.js @@ -7,12 +7,10 @@ vi.mock('../../api/channels.js', () => ({ updateConfig: vi.fn(), toggle: vi.fn(), }, - channelIntakes: { - list: vi.fn(), - }, })); -import { channelIntakes, channels } from '../../api/channels.js'; +import { channels } from '../../api/channels.js'; +import { BACKGROUND_READ } from '../../api/readScheduler.js'; import { loadPortalConnectedMailboxes, parseChannelConfig, prepareFormChannelForWorkspace, saveChannelSettings } from "./channelAdmin.js"; beforeEach(() => { @@ -61,41 +59,36 @@ describe("saveChannelSettings", () => { }); describe("loadPortalConnectedMailboxes", () => { - test("includes mailboxes whose intake targets the portal", async () => { + test("includes mailboxes whose intake targets the portal in one background read", async () => { channels.getAll.mockResolvedValue([ - { id: 1, name: "Support", config: "{}" }, - { id: 2, name: "Sales", config: "{}" }, + { id: 1, name: "Support", intakes: [{ portal_channel_id: 9, workspace_id: 2 }] }, + { id: 2, name: "Sales", intakes: [{ portal_channel_id: null, workspace_id: 9 }] }, ]); - channelIntakes.list.mockImplementation((channelId) => - Promise.resolve( - channelId === 1 - ? [{ portal_channel_id: 9, workspace_id: 2 }] - : [{ portal_channel_id: null, workspace_id: 9 }], - ), - ); const linked = await loadPortalConnectedMailboxes(9); expect(linked.map((channel) => channel.id)).toEqual([1]); + expect(channels.getAll).toHaveBeenCalledTimes(1); + expect(channels.getAll).toHaveBeenCalledWith( + expect.objectContaining({ include_intakes: true }), + BACKGROUND_READ, + ); }); - test("ignores the legacy email_connected_portal_id key and consults intakes", async () => { + test("treats a mailbox with no intakes as unlinked", async () => { channels.getAll.mockResolvedValue([ - { id: 1, name: "Legacy", config: '{"email_connected_portal_id":9}' }, + { id: 1, name: "Legacy", config: '{"email_connected_portal_id":9}', intakes: [] }, ]); - channelIntakes.list.mockResolvedValue([]); const linked = await loadPortalConnectedMailboxes(9); expect(linked).toEqual([]); - expect(channelIntakes.list).toHaveBeenCalledWith(1); }); - test("skips a mailbox whose intake list cannot be read", async () => { - channels.getAll.mockResolvedValue([{ id: 1, name: "Support", config: "{}" }]); - channelIntakes.list.mockRejectedValue(new Error("forbidden")); + test("propagates a failed lookup instead of reporting no mailboxes", async () => { + channels.getAll.mockRejectedValue(new Error("forbidden")); - await expect(loadPortalConnectedMailboxes(9)).resolves.toEqual([]); + await expect(loadPortalConnectedMailboxes(9)).rejects.toThrow("forbidden"); }); }); diff --git a/frontend/src/lib/features/collections/BacklogIterationSectionStub.svelte b/frontend/src/lib/features/collections/BacklogIterationSectionStub.svelte new file mode 100644 index 0000000..2aca25d --- /dev/null +++ b/frontend/src/lib/features/collections/BacklogIterationSectionStub.svelte @@ -0,0 +1,12 @@ + + +
+ {iteration?.name ?? 'Backlog'} ({items.length}) +
diff --git a/frontend/src/lib/features/collections/CollectionBacklog.test.js b/frontend/src/lib/features/collections/CollectionBacklog.test.js index cec5395..1bba696 100644 --- a/frontend/src/lib/features/collections/CollectionBacklog.test.js +++ b/frontend/src/lib/features/collections/CollectionBacklog.test.js @@ -1,5 +1,9 @@ -import { cleanup, render, screen } from "@testing-library/svelte"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { userPreferencesMock } = vi.hoisted(() => ({ + userPreferencesMock: { get: vi.fn(), update: vi.fn() }, +})); const { store, workspaceData, defaultBacklogItems } = vi.hoisted(() => ({ defaultBacklogItems: [ @@ -40,6 +44,9 @@ vi.mock("../../stores/collectionContext.js", () => ({ vi.mock('../../stores/backlogStore.svelte.js', () => ({ backlogStore: { setCount: vi.fn() }, })) +vi.mock("../../api/users.js", () => ({ + userPreferences: userPreferencesMock, +})); vi.mock('../../stores/workspaceDataStore.svelte.js', () => ({ workspaceDataStore: workspaceData, @@ -68,7 +75,7 @@ vi.mock("../../stores/i18n.svelte.js", () => ({ })); vi.mock( "./BacklogIterationSection.svelte", - () => import("./EmptyBacklogChild.svelte"), + () => import("./BacklogIterationSectionStub.svelte"), ); vi.mock("./SubFilterBar.svelte", () => import("./EmptyBacklogChild.svelte")); vi.mock( @@ -89,13 +96,21 @@ vi.mock( ); import CollectionBacklog from "./CollectionBacklog.svelte"; +import { scheduleCollectionReconciliation } from "../../stores/collectionContext.js"; import { screens } from '../../api/configuration.js'; import { iterations } from '../../api/milestones.js'; +import { collectionPreferences } from '../../stores/collectionPreferences.svelte.js'; +beforeEach(() => { + userPreferencesMock.get.mockResolvedValue({ view_state: {}, saved_filters: {} }); + userPreferencesMock.update.mockResolvedValue({}); +}); afterEach(() => { cleanup(); localStorage.clear(); + collectionPreferences.reset(); + iterations.getAll.mockResolvedValue([]); store.backlogItems = [...defaultBacklogItems]; store.backlogPagination = { total_items: 293 }; store.backlogHasMore = true; @@ -103,7 +118,8 @@ afterEach(() => { }); describe("backlog creation events", () => { - it("routes a created item through the store instead of writing the array", async () => { + it("reconciles a created item instead of writing the array", async () => { + scheduleCollectionReconciliation.mockClear(); store.insertCreatedItem.mockClear(); render(CollectionBacklog, { workspaceId: 1 }); @@ -113,12 +129,12 @@ describe("backlog creation events", () => { }), ); + // Configured statuses decide backlog membership, so the created item is + // routed through an authoritative reconcile rather than inserted directly. await vi.waitFor(() => - expect(store.insertCreatedItem).toHaveBeenCalledWith( - { id: 55, workspace_id: 1 }, - { backlog: true }, - ), + expect(scheduleCollectionReconciliation).toHaveBeenCalled(), ); + expect(store.insertCreatedItem).not.toHaveBeenCalled(); }); }); @@ -150,6 +166,69 @@ describe("backlog field availability", () => { }); }); +describe("completed iteration visibility", () => { + const activeIteration = { + id: 11, + name: "Current sprint", + status: "active", + is_global: false, + workspace_id: 1, + }; + const completedIteration = { + id: 10, + name: "Old sprint", + status: "completed", + is_global: false, + workspace_id: 1, + }; + + it("hides completed iterations by default and reveals them from the toggle", async () => { + iterations.getAll.mockResolvedValue([activeIteration, completedIteration]); + render(CollectionBacklog, { workspaceId: 1 }); + + expect( + await screen.findByTestId("backlog-iteration-section-11"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("backlog-iteration-section-10"), + ).not.toBeInTheDocument(); + + await fireEvent.click( + screen.getByTestId("backlog-show-completed-iterations"), + ); + + expect( + await screen.findByTestId("backlog-iteration-section-10"), + ).toHaveAttribute("data-iteration-status", "completed"); + }); + + it("persists the show-completed-iterations preference per view", async () => { + iterations.getAll.mockResolvedValue([activeIteration, completedIteration]); + render(CollectionBacklog, { workspaceId: 1 }); + + await fireEvent.click( + await screen.findByTestId("backlog-show-completed-iterations"), + ); + + expect( + collectionPreferences.viewStateValue(1, null, "backlog", "show_completed_iterations"), + ).toBe(true); + }); + + it("restores the stored preference when the view mounts", async () => { + iterations.getAll.mockResolvedValue([activeIteration, completedIteration]); + collectionPreferences.viewState = { + "workspace-1": { backlog: { show_completed_iterations: true } }, + }; + + render(CollectionBacklog, { workspaceId: 1 }); + + expect( + await screen.findByTestId("backlog-iteration-section-10"), + ).toBeInTheDocument(); + }); +}); + describe("backlog counts", () => { it.each([ [293, [], "293 items ¡ 2 shown", "291 remaining"], diff --git a/frontend/src/lib/features/collections/CollectionNavigation.test.js b/frontend/src/lib/features/collections/CollectionNavigation.test.js index 9d81b7f..f4dbf97 100644 --- a/frontend/src/lib/features/collections/CollectionNavigation.test.js +++ b/frontend/src/lib/features/collections/CollectionNavigation.test.js @@ -4,17 +4,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const { collectionStore } = vi.hoisted(() => ({ collectionStore: { + boardCollection: { name: 'All of mine', created_by: 1 }, collectionName: 'All of mine', itemsPagination: { total_items: 293 }, collectionTotal: 671, }, })); +const mocks = vi.hoisted(() => ({ + enabledViewIds: vi.fn(() => ['backlog', 'board', 'list', 'tree', 'map', 'roadmap']), +})); + vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => (key === 'layout.items' ? 'items' : 'Collection'), })); vi.mock('../../stores/collectionContext.js', () => ({ collectionStore })); +vi.mock('../../stores/viewSettings.svelte.js', () => ({ + viewSettingsStore: { + load: vi.fn().mockResolvedValue(undefined), + enabledViewIds: mocks.enabledViewIds, + }, +})); +vi.mock('../../stores/auth.svelte.js', () => ({ + authStore: { currentUser: { id: 1 } }, +})); vi.mock('../../router.js', () => ({ navigate: vi.fn(), currentRoute: writable({ view: 'collection-board' }), @@ -26,7 +40,12 @@ vi.mock('../../stores/ui.svelte.js', () => ({ import CollectionNavigation from './CollectionNavigation.svelte'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mocks.enabledViewIds.mockReturnValue(['backlog', 'board', 'list', 'tree', 'map', 'roadmap']); + collectionStore.boardCollection = { name: 'All of mine', created_by: 1 }; +}); describe('collection navigation item count', () => { it.each([ @@ -45,3 +64,30 @@ describe('collection navigation item count', () => { ); }); }); + +describe('collection navigation visibility', () => { + it('renders only the views enabled for the collection scope', () => { + mocks.enabledViewIds.mockReturnValue(['board', 'list']); + + render(CollectionNavigation, { collectionId: '7' }); + + expect(screen.getByTestId('collection-nav-board')).toBeInTheDocument(); + expect(screen.getByTestId('collection-nav-list')).toBeInTheDocument(); + expect(screen.queryByTestId('collection-nav-backlog')).not.toBeInTheDocument(); + expect(screen.queryByTestId('collection-nav-tree')).not.toBeInTheDocument(); + }); + + it('offers the navigation-config link to the collection creator', () => { + render(CollectionNavigation, { collectionId: '7' }); + + expect(screen.getByTestId('collection-nav-config-link')).toBeInTheDocument(); + }); + + it('hides the navigation-config link from non-creators', () => { + collectionStore.boardCollection = { name: 'All of mine', created_by: 99 }; + + render(CollectionNavigation, { collectionId: '7' }); + + expect(screen.queryByTestId('collection-nav-config-link')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/features/collections/CollectionTree.test.js b/frontend/src/lib/features/collections/CollectionTree.test.js index d21c6c1..1e57a70 100644 --- a/frontend/src/lib/features/collections/CollectionTree.test.js +++ b/frontend/src/lib/features/collections/CollectionTree.test.js @@ -1,9 +1,11 @@ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/svelte'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { tick } from 'svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { getManyAncestors } = vi.hoisted(() => ({ - getManyAncestors: vi.fn(), +const { getHierarchy, getLevelCounts, preferencesState } = vi.hoisted(() => ({ + getHierarchy: vi.fn(), + getLevelCounts: vi.fn(), + preferencesState: { viewState: {} }, })); vi.mock('../../stores/collectionContext.js', async () => { @@ -12,35 +14,67 @@ vi.mock('../../stores/collectionContext.js', async () => { }); vi.mock('../../stores/auth.svelte.js', () => ({ authStore: { currentUser: { id: 1 } }, -})) +})); +vi.mock('../../stores/workspacePermissions.svelte.js', () => ({ + workspacePermissions: { canEdit: () => true, canAdminWorkspace: () => true }, +})); vi.mock('../../stores/itemTestCaseLinksStore.svelte.js', () => ({ itemTestCaseLinksStore: { - initialize: vi.fn(), - loadForItems: vi.fn().mockResolvedValue(undefined), - get: vi.fn().mockReturnValue(null), - }, -})) + initialize: vi.fn(), + loadForItems: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockReturnValue(null), + }, +})); vi.mock('../../stores/workspaceDataStore.svelte.js', () => ({ workspaceDataStore: { - workspace: { id: 1, name: 'Windshift', key: 'WS' }, - itemTypes: [], - statuses: [], - statusCategories: [], - priorities: [], - users: [], - milestones: [], - iterations: [], - projects: [], - customFieldDefinitions: [], - initialize: vi.fn().mockResolvedValue(undefined), - initializeGlobal: vi.fn().mockResolvedValue(undefined), - }, + workspace: { id: 1, name: 'Windshift', key: 'WS' }, + itemTypes: [ + { id: 10, name: 'Task', hierarchy_level: 3 }, + { id: 11, name: 'Bug', hierarchy_level: 3 }, + { id: 12, name: 'Sub-task', hierarchy_level: -1 }, + ], + statuses: [], + statusCategories: [], + priorities: [], + users: [], + milestones: [], + iterations: [], + projects: [], + customFieldDefinitions: [], + initialized: false, + workspaceId: null, + initialize: vi.fn().mockResolvedValue(undefined), + initializeGlobal: vi.fn().mockResolvedValue(undefined), + hydrateUsers: vi.fn().mockResolvedValue(undefined), + }, })); vi.mock('../../api/items.js', () => ({ - items: { getManyAncestors }, + items: { getHierarchy, getLevelCounts }, })); +vi.mock('../../stores/collectionPreferences.svelte.js', () => { + const scopeId = (ws, col) => (col ? `collection-${col}` : `workspace-${ws ?? 'global'}`); + return { + collectionPreferences: { + viewStateValue(ws, col, view, key) { + const state = preferencesState.viewState[scopeId(ws, col)]?.[view]; + return state && Object.hasOwn(state, key) ? state[key] : undefined; + }, + patchViewState(ws, col, view, patch) { + const scope = scopeId(ws, col); + const currentScope = preferencesState.viewState[scope] ?? {}; + preferencesState.viewState = { + ...preferencesState.viewState, + [scope]: { + ...currentScope, + [view]: { ...(currentScope[view] ?? {}), ...patch }, + }, + }; + }, + }, + }; +}); vi.mock('../../stores/workspaceGradient.svelte.js', () => ({ useGradientStyles: () => ({ backgroundStyle: '', @@ -54,8 +88,15 @@ vi.mock('../../stores/workspaceGradient.svelte.js', () => ({ vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key, params = {}) => { const strings = { - 'layout.items': 'items', - 'collections.itemsShown': '{count} shown', + 'collectionHierarchy.selectLevel': 'Select hierarchy level', + 'collectionHierarchy.levelN': 'Level {level}', + 'collectionHierarchy.itemsCount': '{count} items', + 'collectionTree.tree': 'Tree', + 'collectionTree.expandAll': 'Expand All', + 'collectionTree.collapseAll': 'Collapse All', + 'collectionTree.noWorkItemsYet': 'No work items yet', + 'collectionTree.createFirstWorkItem': 'Create your first work item.', + 'collectionTree.loading': 'Loading...', 'common.loadMore': 'Load more', 'common.loading': 'Loading', 'common.remaining': 'remaining', @@ -73,25 +114,23 @@ vi.mock('../../dialogs/TestCaseViewModal.svelte', () => import('./EmptyBacklogCh import CollectionTree from './CollectionTree.svelte'; import { collectionTreeStore } from './collectionTreeStoreMock.svelte.js'; -import { items } from '../../api/items.js'; -function item(id, parentId, title = `Item ${id}`) { +function item(id, parentId, extra = {}) { return { id, parent_id: parentId, - title, + title: `Item ${id}`, workspace_item_number: id, + workspace_id: 1, + workspace_key: 'WS', status_id: 1, - created_at: '2026-01-01T00:00:00Z', + frac_index: String(id), + ...extra, }; } -function deferred() { - let resolve; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; +function group(rootId, items, { total = items.length, hasMore = false } = {}) { + return { root_id: rootId, total, has_more: hasMore, items }; } function rowIndent(testId) { @@ -99,6 +138,11 @@ function rowIndent(testId) { return screen.getByTestId(`tree-indent-${id}`).style.paddingLeft; } +beforeEach(() => { + getHierarchy.mockResolvedValue([]); + getLevelCounts.mockResolvedValue([]); +}); + afterEach(() => { cleanup(); localStorage.clear(); @@ -108,178 +152,191 @@ afterEach(() => { collectionTreeStore.itemsHasMore = false; collectionTreeStore.itemsLoadingMore = false; collectionTreeStore.itemsRemainingCount = 0; - vi.clearAllMocks(); + collectionTreeStore.loadMoreItems.mockReset(); + collectionTreeStore.ensureLevelCounts.mockClear(); + collectionTreeStore.setHierarchyLevel.mockClear(); + collectionTreeStore.levelCounts = []; + collectionTreeStore.levelCountsLoading = false; + collectionTreeStore.hierarchyLevel = null; + collectionTreeStore.effectiveSubFilterQL = ''; + preferencesState.viewState = {}; + getHierarchy.mockReset(); + getLevelCounts.mockReset().mockResolvedValue([]); }); -describe('CollectionTree ancestor context', () => { - it('nests orphaned children under a fetched parent instead of leaving them as roots', async () => { - const parentChain = deferred(); - getManyAncestors.mockReturnValue(parentChain.promise); - collectionTreeStore.items = [item(1, null), item(20, 13)]; +describe('CollectionTree hierarchy loading', () => { + it('renders the level-scoped base rows and nests their eagerly loaded descendants', async () => { + getHierarchy.mockResolvedValue([ + group(1, [item(20, 1, { has_children: true }), item(21, 1), item(30, 20)]), + ]); + collectionTreeStore.items = [item(1, null, { has_children: true })]; render(CollectionTree, { workspaceId: 1 }); + expect(await screen.findByTestId('tree-row-1')).toBeInTheDocument(); expect(await screen.findByTestId('tree-row-20')).toBeInTheDocument(); - expect(screen.queryByTestId('tree-row-13')).not.toBeInTheDocument(); - expect(rowIndent('tree-row-20')).toBe('0rem'); - - parentChain.resolve([{ item_id: 20, ancestors: [item(13, null, 'BizNut racing game')] }]); - expect(await screen.findByTestId('tree-row-13')).toBeInTheDocument(); - - const parentRow = screen.getByTestId('tree-row-13').closest('.tree-row'); - const childRow = screen.getByTestId('tree-row-20').closest('.tree-row'); - expect( - parentRow.compareDocumentPosition(childRow) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - expect(rowIndent('tree-row-13')).toBe('0rem'); + expect(screen.getByTestId('tree-row-21')).toBeInTheDocument(); + // Deeper descendants are collapsed until their parent is expanded. + expect(screen.queryByTestId('tree-row-30')).not.toBeInTheDocument(); + await fireEvent.click(screen.getByTestId('tree-expand-20')); + expect(await screen.findByTestId('tree-row-30')).toBeInTheDocument(); expect(rowIndent('tree-row-20')).toBe('1.5rem'); - // The new parent is expanded so the child stays visible. - expect(within(parentRow).getByRole('button', { name: 'Collapse' })).toBeInTheDocument(); - expect(getManyAncestors).toHaveBeenCalledTimes(1); - expect(getManyAncestors).toHaveBeenCalledWith([20]); + expect(rowIndent('tree-row-30')).toBe('3rem'); + expect(getHierarchy).toHaveBeenCalledTimes(1); }); - it('resolves multiple missing parents with a single batched request', async () => { - getManyAncestors.mockResolvedValue([ - { item_id: 20, ancestors: [item(13, null, 'First parent')] }, - { item_id: 30, ancestors: [item(40, null, 'Second parent')] }, - ]); - collectionTreeStore.items = [item(20, 13), item(21, 13), item(30, 40)]; + it('shows a parent reference chip when the parent is not drawn', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + // Base root whose real parent (id 99) is above the picked level. + collectionTreeStore.items = [ + item(1, 99, { + has_children: false, + parent_title: 'Parent epic', + parent_workspace_item_number: 99, + }), + ]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-13')).toBeInTheDocument(); - expect(await screen.findByTestId('tree-row-40')).toBeInTheDocument(); - expect(getManyAncestors).toHaveBeenCalledTimes(1); - // One representative orphan per missing parent: siblings share a chain. - expect(getManyAncestors).toHaveBeenCalledWith([20, 30]); - expect(rowIndent('tree-row-21')).toBe('1.5rem'); + expect(await screen.findByTestId('tree-parent-1')).toBeInTheDocument(); + expect(screen.getByTestId('tree-parent-1')).toHaveTextContent('WS-99'); }); - it('keeps a multi-level chain nested from root to leaf', async () => { - getManyAncestors.mockResolvedValue([ - { - item_id: 30, - ancestors: [item(5, null, 'Grandparent'), item(12, 5, 'Middle'), item(20, 12, 'Parent')], - }, - ]); - collectionTreeStore.items = [item(30, 20)]; + it('draws a depth guide only on nested rows', async () => { + getHierarchy.mockResolvedValue([group(1, [item(20, 1)])]); + collectionTreeStore.items = [item(1, null, { has_children: true })]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-30')).toBeInTheDocument(); - expect(rowIndent('tree-row-5')).toBe('0rem'); - expect(rowIndent('tree-row-12')).toBe('1.5rem'); - expect(rowIndent('tree-row-20')).toBe('3rem'); - expect(rowIndent('tree-row-30')).toBe('4.5rem'); + const rootIndent = await screen.findByTestId('tree-indent-1'); + expect(rootIndent.getAttribute('style') ?? '').not.toContain('repeating-linear-gradient'); + expect(rootIndent.style.paddingLeft).toBe(''); + + const childIndent = screen.getByTestId('tree-indent-20'); + expect(childIndent.style.paddingLeft).toBe('1.5rem'); + expect(childIndent.getAttribute('style')).toContain('repeating-linear-gradient'); }); - it('covers a page of many distinct missing parents with one batched request', async () => { - getManyAncestors.mockImplementation((ids) => - Promise.resolve( - ids.map((orphanId) => ({ - item_id: orphanId, - ancestors: [item(orphanId + 1000, null, `Parent ${orphanId}`)], - })), - ), - ); - collectionTreeStore.items = Array.from({ length: 12 }, (_, index) => - item(100 + index, 1100 + index), + it('connects an expanded parent to its children with a branch line', async () => { + getHierarchy.mockResolvedValue([group(1, [item(20, 1)])]); + collectionTreeStore.items = [item(1, null, { has_children: true })]; + + render(CollectionTree, { workspaceId: 1 }); + + const expanded = await screen.findByTestId('tree-indent-1'); + expect(expanded.getAttribute('style')).toContain( + 'background-image: linear-gradient(var(--ds-border), var(--ds-border))', ); + await fireEvent.click(screen.getByTestId('tree-expand-1')); + + expect(screen.queryByTestId('tree-row-20')).not.toBeInTheDocument(); + const collapsed = screen.getByTestId('tree-indent-1'); + expect(collapsed.getAttribute('style') ?? '').not.toContain('linear-gradient'); + }); + + it('renders the parent chip in the key column so it cannot push the title right', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + collectionTreeStore.items = [ + item(1, 99, { + item_type_id: 10, + parent_title: 'Parent epic', + parent_workspace_item_number: 99, + }), + ]; + render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-1111')).toBeInTheDocument(); - expect(getManyAncestors).toHaveBeenCalledTimes(1); - expect(getManyAncestors).toHaveBeenCalledWith([ - 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, - ]); - for (let id = 100; id < 112; id++) { - expect(screen.getByTestId(`tree-row-${id + 1000}`)).toBeInTheDocument(); - } + const chip = await screen.findByTestId('tree-parent-1'); + const title = screen.getByTestId('workspace-item-title-1'); + expect(title.contains(chip)).toBe(false); + expect(chip.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); - it('does not fetch ancestors when every parent is already loaded', async () => { - getManyAncestors.mockResolvedValue([]); - collectionTreeStore.items = [item(13, null), item(20, 13)]; + it('clamps the title cell to two lines', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + collectionTreeStore.items = [item(1, null)]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-20')).toBeInTheDocument(); - expect(rowIndent('tree-row-20')).toBe('1.5rem'); - expect(getManyAncestors).not.toHaveBeenCalled(); + const title = await screen.findByTestId('workspace-item-title-1'); + expect(title.querySelector('span')?.getAttribute('style')).toContain('-webkit-line-clamp: 2'); }); - it('keeps the hierarchy intact when a reload loads the parent itself', async () => { - getManyAncestors.mockResolvedValue([ - { item_id: 20, ancestors: [item(13, null, 'BizNut racing game')] }, - ]); - collectionTreeStore.items = [item(20, 13)]; + it('offers a per-root Load more when a subtree exceeded the cap', async () => { + getHierarchy.mockImplementation(({ perRootOffset }) => + Promise.resolve([ + perRootOffset === 0 + ? group(1, [item(20, 1), item(21, 1)], { total: 4, hasMore: true }) + : group(1, [item(22, 1), item(23, 1)], { total: 4, hasMore: false }), + ]) + ); + collectionTreeStore.items = [item(1, null, { has_children: true })]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-13')).toBeInTheDocument(); - // Simulate toggling the completed filter: the reload now includes the - // parent, so the fetched context must be dropped without a refetch. - collectionTreeStore.items = [item(13, null), item(20, 13)]; - await tick(); + const button = await screen.findByTestId('tree-load-more-button-1'); + expect(button).toHaveTextContent('Load more'); + expect(button).toHaveTextContent('2 remaining'); - expect(rowIndent('tree-row-13')).toBe('0rem'); - expect(rowIndent('tree-row-20')).toBe('1.5rem'); - expect(getManyAncestors).toHaveBeenCalledTimes(1); + await fireEvent.click(button); + + expect(await screen.findByTestId('tree-row-22')).toBeInTheDocument(); + expect(screen.queryByTestId('tree-load-more-button-1')).not.toBeInTheDocument(); }); - it('shows the filter-scoped item total in the header, not the rendered rows', async () => { - getManyAncestors.mockResolvedValue([]); - collectionTreeStore.items = [item(1, null), item(20, 13)]; - collectionTreeStore.collectionTotal = 1310; - collectionTreeStore.itemsPagination = { total_items: 803, page: 1 }; + it('offers a base-level Load more when the level page is truncated', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + collectionTreeStore.items = [item(1, null)]; + collectionTreeStore.itemsHasMore = true; + collectionTreeStore.itemsRemainingCount = 49; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('page-header-subtitle')).toHaveTextContent( - '1310 items ¡ 803 shown', - ); + const button = await screen.findByTestId('tree-load-more-button'); + expect(button).toHaveTextContent('Load more'); + expect(button).toHaveTextContent('49 remaining'); + + await fireEvent.click(button); + expect(collectionTreeStore.loadMoreItems).toHaveBeenCalledTimes(1); }); - it('omits the shown segment when no item is filtered out', async () => { - getManyAncestors.mockResolvedValue([]); - collectionTreeStore.items = [item(1, null), item(2, null)]; - collectionTreeStore.collectionTotal = 2; - collectionTreeStore.itemsPagination = { total_items: 2, page: 1 }; + it('keeps the default key width when no row shows a parent reference', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + collectionTreeStore.items = [item(1, null)]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('page-header-subtitle')).toHaveTextContent('2 items'); - expect(screen.getByTestId('page-header-subtitle')).not.toHaveTextContent('shown'); + const row = await screen.findByTestId('tree-row-1'); + expect(row.style.gridTemplateColumns).toContain('5rem'); + expect(row.style.gridTemplateColumns).not.toContain('10rem'); }); - it('renders every loaded root instead of a client page of them', async () => { - getManyAncestors.mockResolvedValue([]); - collectionTreeStore.items = Array.from({ length: 60 }, (_, index) => item(index + 1, null)); - collectionTreeStore.collectionTotal = 60; - collectionTreeStore.itemsPagination = { total_items: 60, page: 1 }; + it('widens the key column when a row shows a parent reference', async () => { + getHierarchy.mockResolvedValue([group(1, [])]); + collectionTreeStore.items = [ + item(1, 99, { parent_title: 'Parent epic', parent_workspace_item_number: 99 }), + ]; render(CollectionTree, { workspaceId: 1 }); - expect(await screen.findByTestId('tree-row-60')).toBeInTheDocument(); - expect(screen.queryByText(/Top-level items/)).not.toBeInTheDocument(); + const row = await screen.findByTestId('tree-row-1'); + await waitFor(() => expect(row.style.gridTemplateColumns).toContain('10rem')); }); - it('offers a server continuation when more items remain', async () => { - getManyAncestors.mockResolvedValue([]); + it('renders one level option per hierarchy level with every item type in it', async () => { + collectionTreeStore.levelCounts = [{ level: 3, name: 'Task', total: 4 }]; collectionTreeStore.items = [item(1, null)]; - collectionTreeStore.itemsPagination = { total_items: 300, page: 1 }; - collectionTreeStore.itemsHasMore = true; - collectionTreeStore.itemsRemainingCount = 200; render(CollectionTree, { workspaceId: 1 }); - const button = await screen.findByTestId('tree-load-more-button'); - expect(button).toHaveTextContent('Load more'); - expect(button).toHaveTextContent('200 remaining'); + const trigger = await screen.findByTestId('hierarchy-level-trigger'); + expect(trigger).toHaveTextContent('Task, Bug'); - await fireEvent.click(button); - expect(collectionTreeStore.loadMoreItems).toHaveBeenCalledTimes(1); + await fireEvent.click(trigger); + const option = await screen.findByTestId('hierarchy-level-option-3'); + // Sub-task (-1) is never offered as a level. + expect(screen.queryByTestId('hierarchy-level-option--1')).not.toBeInTheDocument(); + expect(within(option).getByText('Task, Bug')).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/features/collections/ColumnSelector.test.js b/frontend/src/lib/features/collections/ColumnSelector.test.js new file mode 100644 index 0000000..f98c4cb --- /dev/null +++ b/frontend/src/lib/features/collections/ColumnSelector.test.js @@ -0,0 +1,52 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, expect, it, vi } from 'vitest'; + +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +const { default: ColumnSelector } = await import('./ColumnSelector.svelte'); + +afterEach(cleanup); + +const COLUMNS = [ + { field_identifier: 'key', field_type: 'system', display_order: 0, width: 2 }, + { field_identifier: 'title', field_type: 'system', display_order: 1, width: 2 }, + { field_identifier: 'status', field_type: 'system', display_order: 2, width: 2 }, +]; + +it('adds and removes columns and applies the new set', async () => { + const onchange = vi.fn(); + render(ColumnSelector, { props: { columns: COLUMNS, onchange } }); + + await fireEvent.click(screen.getByTestId('column-selector-trigger')); + await screen.findByTestId('column-selector-menu'); + + await fireEvent.click(screen.getByTestId('remove-column-status')); + await fireEvent.click(screen.getByTestId('add-column-priority')); + await fireEvent.click(screen.getByTestId('column-selector-apply')); + + expect(onchange).toHaveBeenCalledWith({ + columns: [ + { field_identifier: 'key', field_type: 'system', display_order: 0, width: 2 }, + { field_identifier: 'title', field_type: 'system', display_order: 1, width: 2 }, + { field_identifier: 'priority', field_type: 'system', display_order: 2, width: 2 }, + ], + }); +}); + +it('keeps required columns and discards edits on cancel', async () => { + const onchange = vi.fn(); + render(ColumnSelector, { props: { columns: COLUMNS, onchange } }); + + await fireEvent.click(screen.getByTestId('column-selector-trigger')); + await screen.findByTestId('column-selector-menu'); + + expect(screen.queryByTestId('remove-column-key')).not.toBeInTheDocument(); + expect(screen.queryByTestId('remove-column-title')).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByTestId('remove-column-status')); + // Cancel restores the original set and never calls onchange. + const cancelButtons = screen.getAllByText('Cancel'); + await fireEvent.click(cancelButtons[cancelButtons.length - 1]); + + expect(onchange).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/lib/features/collections/FilterBuilderModal.test.js b/frontend/src/lib/features/collections/FilterBuilderModal.test.js new file mode 100644 index 0000000..bf0da66 --- /dev/null +++ b/frontend/src/lib/features/collections/FilterBuilderModal.test.js @@ -0,0 +1,48 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../api/queryLanguage.js', () => ({ + queryLanguage: { + getCatalog: vi.fn(async () => ({ fields: [] })), + getValues: vi.fn(async () => []), + }, +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +const { default: FilterBuilderModal } = await import('./FilterBuilderModal.svelte'); + +afterEach(cleanup); + +const NULL_ROW = { + field: { id: 'cf_Risk level', name: 'Risk level', type: 'select' }, + operator: 'IS NULL', + value: '', + values: [], +}; + +describe('FilterBuilderModal', () => { + it('applies a filter whose only row uses a null operator', async () => { + const onApply = vi.fn(); + render(FilterBuilderModal, { + props: { isOpen: true, initialRows: [NULL_ROW], onApply }, + }); + + const apply = await screen.findByTestId('dynamic-filter-apply'); + expect(apply).toBeEnabled(); + await fireEvent.click(apply); + + expect(onApply).toHaveBeenCalledWith([NULL_ROW]); + }); + + it('keeps apply disabled for a row that still needs a value', async () => { + render(FilterBuilderModal, { + props: { + isOpen: true, + initialRows: [{ ...NULL_ROW, operator: '=' }], + }, + }); + + expect(await screen.findByTestId('dynamic-filter-apply')).toBeDisabled(); + }); +}); diff --git a/frontend/src/lib/features/collections/FilterMenu.test.js b/frontend/src/lib/features/collections/FilterMenu.test.js index 245c11c..f294949 100644 --- a/frontend/src/lib/features/collections/FilterMenu.test.js +++ b/frontend/src/lib/features/collections/FilterMenu.test.js @@ -25,6 +25,7 @@ vi.mock('../../api/queues.js', () => ({ })); vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), warningToast: vi.fn(), })); @@ -170,6 +171,98 @@ describe('FilterMenu personal filters', () => { const stub = await screen.findByTestId('filter-builder-stub'); expect(stub.dataset.saveMode).toBe('true'); }); + + it('treats a null-operator row as an active filter', async () => { + const nullRows = [ + { field: { id: 'cf_Risk level', type: 'select' }, operator: 'IS NULL', value: '', values: [] }, + ]; + await collectionPreferences.ensureLoaded(1); + await openMenu({ activeRows: nullRows, applied: false }); + + expect(screen.getByTestId('filter-menu-save')).toBeInTheDocument(); + expect(screen.getByTestId('dynamic-filter-toggle')).toHaveTextContent('1'); + }); +}); + +describe('FilterMenu active filter visibility', () => { + it('lists the applied rows and clears them', async () => { + const onclear = vi.fn(); + await collectionPreferences.ensureLoaded(1); + await openMenu({ onclear }); + + expect(screen.getByTestId('filter-menu-active')).toBeInTheDocument(); + expect(screen.getByTestId('filter-menu-active-row-0')).toHaveTextContent('priority = High'); + + await fireEvent.click(screen.getByTestId('filter-menu-clear')); + expect(onclear).toHaveBeenCalledTimes(1); + }); + + it('marks the applied saved personal filter', async () => { + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: { + 'collection-5': [ + { + id: 'server', + name: 'Server', + ql: '`priority` = "High"', + rows: [{ field: { id: 'priority', type: 'text' }, operator: '=', value: 'High' }], + pinned: false, + }, + ], + }, + }); + await collectionPreferences.ensureLoaded(1); + await openMenu(); + + expect(await screen.findByTestId('filter-menu-personal-applied-server')).toBeInTheDocument(); + }); + + it('clears the live filter when the applied personal filter is deleted', async () => { + const onclear = vi.fn(); + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: { + 'collection-5': [ + { + id: 'server', + name: 'Server', + ql: '`priority` = "High"', + rows: [{ field: { id: 'priority', type: 'text' }, operator: '=', value: 'High' }], + pinned: false, + }, + ], + }, + }); + await collectionPreferences.ensureLoaded(1); + await openMenu({ onclear }); + + await fireEvent.click(await screen.findByTestId('filter-menu-personal-delete-server')); + expect(onclear).toHaveBeenCalledTimes(1); + }); + + it('keeps the live filter when a different personal filter is deleted', async () => { + const onclear = vi.fn(); + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: { + 'collection-5': [ + { + id: 'other', + name: 'Other', + ql: '`priority` = "Low"', + rows: [{ field: { id: 'priority', type: 'text' }, operator: '=', value: 'Low' }], + pinned: false, + }, + ], + }, + }); + await collectionPreferences.ensureLoaded(1); + await openMenu({ onclear }); + + await fireEvent.click(await screen.findByTestId('filter-menu-personal-delete-other')); + expect(onclear).not.toHaveBeenCalled(); + }); }); describe('FilterMenu shared filters', () => { @@ -211,4 +304,104 @@ describe('FilterMenu shared filters', () => { expect.objectContaining({ name: 'Stub', ql_query: '`priority` = "High"' }) ); }); + + it('clears the live filter when the applied shared view is deleted', async () => { + const onclear = vi.fn(); + mocks.list.mockResolvedValue([ + { + id: 9, + name: 'Team triage', + ql: '`priority` = "High"', + filter_state: JSON.stringify([ + { field: { id: 'priority', type: 'text' }, operator: '=', value: 'High' }, + ]), + builtin: false, + }, + ]); + + await openMenu({ onclear }); + await fireEvent.click(await screen.findByTestId('filter-menu-shared-delete-9')); + + await vi.waitFor(() => expect(onclear).toHaveBeenCalledTimes(1)); + }); + + it('reorders shared views through the queues API', async () => { + mocks.list.mockResolvedValue([ + { id: 9, name: 'First', ql: '`priority` = "High"', filter_state: null, builtin: false }, + { id: 10, name: 'Second', ql: '`priority` = "Low"', filter_state: null, builtin: false }, + ]); + + await openMenu(); + await fireEvent.click(await screen.findByTestId('filter-menu-shared-down-9')); + + await vi.waitFor(() => + expect(mocks.reorder).toHaveBeenCalledWith('WS', 5, [10, 9]) + ); + }); + + it('renames a shared view through the queues API', async () => { + mocks.list.mockResolvedValue([ + { id: 9, name: 'Team triage', ql: '`status` = "Open"', filter_state: null, builtin: false }, + ]); + + await openMenu(); + await fireEvent.click(await screen.findByTestId('filter-menu-shared-rename-9')); + const input = await screen.findByTestId('filter-menu-shared-rename-input-9'); + await fireEvent.input(input, { target: { value: 'Renamed triage' } }); + await fireEvent.click(screen.getByTestId('filter-menu-shared-rename-save-9')); + + await vi.waitFor(() => + expect(mocks.updateQueue).toHaveBeenCalledWith(9, { + name: 'Renamed triage', + ql_query: '`status` = "Open"', + filter_state: null, + }) + ); + }); +}); + +describe('FilterMenu personal filter management', () => { + function personalFilter(overrides = {}) { + return { + id: 'server', + name: 'Server', + ql: '`priority` = "High"', + rows: [{ field: { id: 'priority', type: 'text' }, operator: '=', value: 'High' }], + pinned: false, + ...overrides, + }; + } + + it('renames a personal filter and persists it', async () => { + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: { 'collection-5': [personalFilter()] }, + }); + await collectionPreferences.ensureLoaded(1); + await openMenu(); + + await fireEvent.click(await screen.findByTestId('filter-menu-personal-rename-server')); + const input = await screen.findByTestId('filter-menu-personal-rename-input-server'); + await fireEvent.input(input, { target: { value: 'Renamed server' } }); + await fireEvent.click(screen.getByTestId('filter-menu-personal-rename-save-server')); + + expect(collectionPreferences.getSavedFilters(1, 5)[0].name).toBe('Renamed server'); + }); + + it('pins a personal filter and persists it', async () => { + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: { 'collection-5': [personalFilter()] }, + }); + await collectionPreferences.ensureLoaded(1); + await openMenu(); + + await fireEvent.click(await screen.findByTestId('filter-menu-personal-pin-server')); + + expect(screen.getByTestId('filter-menu-personal-pin-server')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(collectionPreferences.getSavedFilters(1, 5)[0].pinned).toBe(true); + }); }); diff --git a/frontend/src/lib/features/collections/FilterMenuStub.svelte b/frontend/src/lib/features/collections/FilterMenuStub.svelte index c7e8777..57f34df 100644 --- a/frontend/src/lib/features/collections/FilterMenuStub.svelte +++ b/frontend/src/lib/features/collections/FilterMenuStub.svelte @@ -1,2 +1,11 @@ - -
+ + +
diff --git a/frontend/src/lib/features/collections/SubFilterBar.test.js b/frontend/src/lib/features/collections/SubFilterBar.test.js index 3c98455..740c2cf 100644 --- a/frontend/src/lib/features/collections/SubFilterBar.test.js +++ b/frontend/src/lib/features/collections/SubFilterBar.test.js @@ -1,18 +1,45 @@ import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, expect, it, vi } from 'vitest'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -vi.mock('../../stores/collectionContext.svelte.js', () => ({ - collectionStore: { subFilterRows: [], subFilterQL: '', showCompleted: false }, -})); +vi.mock('../../stores/collectionContext.svelte.js', async () => { + const { collectionStore } = await import('./subFilterBarStoreMock.svelte.js'); + return { collectionStore }; +}); vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); vi.mock('./FilterMenu.svelte', async () => ({ default: (await import('./FilterMenuStub.svelte')).default, })); +const { collectionStore } = await import('./subFilterBarStoreMock.svelte.js'); const { default: SubFilterBar } = await import('./SubFilterBar.svelte'); + +beforeEach(() => { + collectionStore.clearSubFilter(); +}); + afterEach(cleanup); it('omits the completion toggle when the board owns completion visibility', () => { render(SubFilterBar, { props: { showCompletionToggle: false } }); expect(screen.queryByTestId('collection-hide-completed')).toBeNull(); }); + +it('re-syncs the active rows when the store scope resets', async () => { + collectionStore.setSubFilter('`priority` = "High"', [ + { field: { id: 'priority' }, operator: '=', value: 'High' }, + ]); + render(SubFilterBar, { props: {} }); + + const stub = screen.getByTestId('filter-menu-stub'); + expect(stub.dataset.activeCount).toBe('1'); + expect(stub.dataset.applied).toBe('true'); + + // A scope change (MainApp reuses the view instance) clears the store; the + // bar must stop showing the previous collection's filter. + collectionStore.clearSubFilter(); + await tick(); + + expect(stub.dataset.activeCount).toBe('0'); + expect(stub.dataset.applied).toBe('false'); +}); diff --git a/frontend/src/lib/features/collections/WorkItemActionsStub.svelte b/frontend/src/lib/features/collections/WorkItemActionsStub.svelte new file mode 100644 index 0000000..65c5bb3 --- /dev/null +++ b/frontend/src/lib/features/collections/WorkItemActionsStub.svelte @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/lib/features/collections/WorkItemCellStub.svelte b/frontend/src/lib/features/collections/WorkItemCellStub.svelte new file mode 100644 index 0000000..497437e --- /dev/null +++ b/frontend/src/lib/features/collections/WorkItemCellStub.svelte @@ -0,0 +1,9 @@ + + + + {item.title}:{column.field_identifier} + diff --git a/frontend/src/lib/features/collections/WorkItemListFooter.test.js b/frontend/src/lib/features/collections/WorkItemListFooter.test.js new file mode 100644 index 0000000..f885dc3 --- /dev/null +++ b/frontend/src/lib/features/collections/WorkItemListFooter.test.js @@ -0,0 +1,46 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../../stores/i18n.svelte.js', () => ({ + t: (key) => key, + i18n: { locale: 'en-US' }, +})); + +import WorkItemListFooter from './WorkItemListFooter.svelte'; + +afterEach(cleanup); + +describe('WorkItemListFooter', () => { + test('renders the pager and reports page changes with the effective page size', async () => { + const onpageChange = vi.fn(); + const onpageSizeChange = vi.fn(); + + render(WorkItemListFooter, { + props: { + pagination: { page: 1, total_items: 3, limit: 2 }, + itemCount: 2, + onpageChange, + onpageSizeChange, + }, + }); + + await fireEvent.click(screen.getByTestId('pagination-next')); + + expect(onpageChange).toHaveBeenCalledTimes(1); + expect(onpageChange).toHaveBeenCalledWith({ detail: { page: 2, itemsPerPage: 2 } }); + }); + + test('renders nothing when the result set is not pageable', () => { + render(WorkItemListFooter, { + props: { pagination: { page: 1, total_items: 0, limit: 50 }, itemCount: 0 }, + }); + + expect(screen.queryByTestId('pagination-next')).toBeNull(); + }); + + test('renders nothing when no pagination metadata is available', () => { + render(WorkItemListFooter, { props: { pagination: null, itemCount: 5 } }); + + expect(screen.queryByTestId('pagination-next')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/features/collections/WorkItemListRow.test.js b/frontend/src/lib/features/collections/WorkItemListRow.test.js new file mode 100644 index 0000000..f494005 --- /dev/null +++ b/frontend/src/lib/features/collections/WorkItemListRow.test.js @@ -0,0 +1,103 @@ +import { cleanup, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createRawSnippet } from 'svelte'; + +vi.mock('./ListCellRenderer.svelte', async () => ({ + default: (await import('./WorkItemCellStub.svelte')).default, +})); + +vi.mock('../../layout/DropdownMenu.svelte', async () => ({ + default: (await import('./WorkItemActionsStub.svelte')).default, +})); + +import WorkItemListRow from './WorkItemListRow.svelte'; + +// LazyRender gates rows behind IntersectionObserver, which the test DOM lacks; +// report the sentinel as immediately visible so the row body renders. +class ImmediateIntersectionObserver { + constructor(callback) { + this.callback = callback; + } + observe() { + this.callback([{ isIntersecting: true }], this); + } + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} + +const COLUMNS = [ + { field_identifier: 'key' }, + { field_identifier: 'title' }, +]; + +function baseProps(overrides = {}) { + return { + item: { id: 7, title: 'Alpha' }, + columns: COLUMNS, + gridTemplateColumns: '1fr 2fr', + gridMinWidth: '12rem', + actions: [{ label: 'view' }], + rowTestid: 'row-7', + ...overrides, + }; +} + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +describe('WorkItemListRow', () => { + test('renders one cell per column in order plus the row action menu', () => { + render(WorkItemListRow, { props: baseProps() }); + + const cells = screen.getAllByTestId('stub-cell'); + expect(cells.map((cell) => cell.getAttribute('data-column'))).toEqual(['key', 'title']); + expect(screen.getByTestId('stub-actions')).toHaveTextContent('1'); + + const row = screen.getByTestId('row-7'); + expect(row).toHaveAttribute('data-item-row'); + expect(row).toHaveAttribute('data-item-id', '7'); + }); + + test('forwards the per-item edit permission to every cell', () => { + render(WorkItemListRow, { props: baseProps({ canEdit: true }) }); + + const cells = screen.getAllByTestId('stub-cell'); + expect(cells).toHaveLength(2); + for (const cell of cells) { + expect(cell).toHaveAttribute('data-can-edit', 'true'); + } + }); + + test('renders the leading snippet before the first data cell', () => { + const leading = createRawSnippet(() => ({ + render: () => '', + })); + + render(WorkItemListRow, { props: baseProps({ leading }) }); + + const row = screen.getByTestId('row-7'); + const lead = screen.getByTestId('lead'); + const firstCell = screen.getAllByTestId('stub-cell')[0]; + expect(row).toContainElement(lead); + expect(lead.compareDocumentPosition(firstCell) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test('only registers a native drag source when draggable is set', () => { + render(WorkItemListRow, { props: baseProps({ draggable: true }) }); + expect(screen.getByTestId('row-7')).toHaveAttribute('draggable', 'true'); + + cleanup(); + + render(WorkItemListRow, { props: baseProps() }); + expect(screen.getByTestId('row-7')).not.toHaveAttribute('draggable'); + }); +}); diff --git a/frontend/src/lib/features/collections/boardConfigPayload.test.js b/frontend/src/lib/features/collections/boardConfigPayload.test.js index d72d4a5..587a34f 100644 --- a/frontend/src/lib/features/collections/boardConfigPayload.test.js +++ b/frontend/src/lib/features/collections/boardConfigPayload.test.js @@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'; import { buildBoardConfigurationPayload, legacyFieldsForCompletedVisibility, + normalizeCompletedLimit, + DEFAULT_COMPLETED_LIMIT, + MIN_COMPLETED_LIMIT, + MAX_COMPLETED_LIMIT, } from './boardConfigPayload.js'; describe('board configuration payload', () => { @@ -78,12 +82,24 @@ describe('board configuration payload', () => { showRightmostColumnLast50: false, completedItemRetentionDays: null, }); + expect(legacyFieldsForCompletedVisibility('limit')).toEqual({ + showRightmostColumnLast50: false, + completedItemRetentionDays: null, + }); expect(legacyFieldsForCompletedVisibility('none')).toEqual({ showRightmostColumnLast50: false, completedItemRetentionDays: null, }); }); + it('coerces a stored completed-item cap into the accepted range', () => { + expect(normalizeCompletedLimit(undefined)).toBe(DEFAULT_COMPLETED_LIMIT); + expect(normalizeCompletedLimit('250')).toBe(250); + expect(normalizeCompletedLimit(0)).toBe(MIN_COMPLETED_LIMIT); + expect(normalizeCompletedLimit(MAX_COMPLETED_LIMIT + 1)).toBe(MAX_COMPLETED_LIMIT); + expect(normalizeCompletedLimit('not-a-number')).toBe(DEFAULT_COMPLETED_LIMIT); + }); + it('persists an explicit completed-visibility preset and clears the cap', () => { const payload = buildBoardConfigurationPayload( { show_rightmost_column_last_50: true, completed_item_retention_days: 30 }, @@ -93,16 +109,34 @@ describe('board configuration payload', () => { expect(payload.show_rightmost_column_last_50).toBe(false); expect(payload.completed_item_retention_days).toBe(7); expect(payload.view_settings.completed_visibility).toBe('last_week'); + expect(payload.view_settings).not.toHaveProperty('completed_limit'); + }); + + it('persists the limit preset with its cap and drops it for other presets', () => { + const limited = buildBoardConfigurationPayload( + {}, + { completedVisibility: 'limit', completedLimit: 250 } + ); + expect(limited.view_settings.completed_visibility).toBe('limit'); + expect(limited.view_settings.completed_limit).toBe(250); + + const unlimited = buildBoardConfigurationPayload( + {}, + { completedVisibility: 'all', completedLimit: 250 } + ); + expect(unlimited.view_settings.completed_visibility).toBe('all'); + expect(unlimited.view_settings).not.toHaveProperty('completed_limit'); }); - it('round-trips a stored preset and keeps its legacy fields aligned', () => { + it('round-trips a stored limit preset and its cap', () => { const payload = buildBoardConfigurationPayload({ show_rightmost_column_last_50: false, completed_item_retention_days: null, - view_settings: { completed_visibility: 'current_iteration' }, + view_settings: { completed_visibility: 'limit', completed_limit: 120 }, }); - expect(payload.view_settings.completed_visibility).toBe('current_iteration'); + expect(payload.view_settings.completed_visibility).toBe('limit'); + expect(payload.view_settings.completed_limit).toBe(120); expect(payload.show_rightmost_column_last_50).toBe(false); expect(payload.completed_item_retention_days).toBeNull(); }); diff --git a/frontend/src/lib/features/collections/collectionFilters.test.js b/frontend/src/lib/features/collections/collectionFilters.test.js new file mode 100644 index 0000000..7e7c052 --- /dev/null +++ b/frontend/src/lib/features/collections/collectionFilters.test.js @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { collectionSubFilterQL } from './collectionFilters.js'; + +describe('collectionSubFilterQL', () => { + it('appends the completed-item exclusion by default', () => { + expect(collectionSubFilterQL()).toBe('status_completed = false'); + expect(collectionSubFilterQL('priority = "High"')).toBe( + '(priority = "High") AND status_completed = false' + ); + }); + + it('returns the sub-filter unchanged when completed items are included', () => { + expect(collectionSubFilterQL('', { hideCompleted: false })).toBe(''); + expect(collectionSubFilterQL('priority = "High"', { hideCompleted: false })).toBe( + 'priority = "High"' + ); + }); +}); diff --git a/frontend/src/lib/features/collections/collectionTreeStoreMock.svelte.js b/frontend/src/lib/features/collections/collectionTreeStoreMock.svelte.js index 18a169b..5946e77 100644 --- a/frontend/src/lib/features/collections/collectionTreeStoreMock.svelte.js +++ b/frontend/src/lib/features/collections/collectionTreeStoreMock.svelte.js @@ -11,10 +11,39 @@ export function createCollectionStoreMock() { let itemsHasMore = $state(false); let itemsLoadingMore = $state(false); let itemsRemainingCount = $state(0); + let hierarchyLevel = $state(null); + let levelCounts = $state([]); + let levelCountsLoading = $state(false); const loadMoreItems = vi.fn(); + const ensureLevelCounts = vi.fn().mockResolvedValue([]); + const setHierarchyLevel = vi.fn((level) => { + hierarchyLevel = level; + }); return { loadMoreItems, + ensureLevelCounts, + setHierarchyLevel, + effectiveSubFilterQL: '', + dataGeneration: 0, + get hierarchyLevel() { + return hierarchyLevel; + }, + set hierarchyLevel(value) { + hierarchyLevel = value; + }, + get levelCounts() { + return levelCounts; + }, + set levelCounts(value) { + levelCounts = value; + }, + get levelCountsLoading() { + return levelCountsLoading; + }, + set levelCountsLoading(value) { + levelCountsLoading = value; + }, get itemsHasMore() { return itemsHasMore; }, diff --git a/frontend/src/lib/features/collections/hierarchyLevels.test.js b/frontend/src/lib/features/collections/hierarchyLevels.test.js new file mode 100644 index 0000000..8e3abc9 --- /dev/null +++ b/frontend/src/lib/features/collections/hierarchyLevels.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { buildHierarchyLevels, defaultHierarchyLevel } from './hierarchyLevels.js'; + +describe('buildHierarchyLevels', () => { + it('merges item types and counts, skipping sub-tasks', () => { + const levels = buildHierarchyLevels( + [ + { id: 1, name: 'Task', hierarchy_level: 3 }, + { id: 2, name: 'Bug', hierarchy_level: 3 }, + { id: 3, name: 'Sub-task', hierarchy_level: -1 }, + ], + [ + { level: 3, name: 'Task', total: 4 }, + { level: 4, name: '', total: 2 }, + ] + ); + + expect(levels.map((entry) => entry.level)).toEqual([3, 4]); + expect(levels[0]).toMatchObject({ level: 3, total: 4, label: 'Task, Bug' }); + expect(levels[1]).toMatchObject({ level: 4, total: 2, label: 'Level 4' }); + }); + + it('includes a level known only from counts', () => { + const levels = buildHierarchyLevels([], [{ level: 0, name: 'Initiative', total: 0 }]); + + expect(levels).toEqual([ + { level: 0, types: [], name: 'Initiative', total: 0, label: 'Initiative' }, + ]); + }); +}); + +describe('defaultHierarchyLevel', () => { + it('picks the shallowest level with matches', () => { + expect( + defaultHierarchyLevel([ + { level: 0, total: 0 }, + { level: 1, total: 0 }, + { level: 2, total: 3 }, + ]) + ).toBe(2); + }); + + it('falls back to the shallowest known level when nothing matches', () => { + expect( + defaultHierarchyLevel([ + { level: 1, total: 0 }, + { level: 2, total: 0 }, + ]) + ).toBe(1); + }); + + it('returns null when no level is known', () => { + expect(defaultHierarchyLevel([])).toBeNull(); + }); +}); diff --git a/frontend/src/lib/features/collections/subFilterBarStoreMock.svelte.js b/frontend/src/lib/features/collections/subFilterBarStoreMock.svelte.js new file mode 100644 index 0000000..ac35cf8 --- /dev/null +++ b/frontend/src/lib/features/collections/subFilterBarStoreMock.svelte.js @@ -0,0 +1,41 @@ +// Reactive stand-in for the class-based collectionStore so SubFilterBar tests +// can reset subFilterRows the way a scope change does. +export function createSubFilterStoreMock() { + let subFilterRows = $state([]); + let subFilterQL = $state(''); + let showCompleted = $state(false); + + return { + get subFilterRows() { + return subFilterRows; + }, + set subFilterRows(value) { + subFilterRows = value; + }, + get subFilterQL() { + return subFilterQL; + }, + set subFilterQL(value) { + subFilterQL = value; + }, + get showCompleted() { + return showCompleted; + }, + set showCompleted(value) { + showCompleted = value; + }, + setSubFilter(ql, rows = []) { + subFilterQL = ql; + subFilterRows = rows; + }, + clearSubFilter() { + subFilterQL = ''; + subFilterRows = []; + }, + setShowCompleted(value) { + showCompleted = value; + }, + }; +} + +export const collectionStore = createSubFilterStoreMock(); diff --git a/frontend/src/lib/features/items/ApprovalsTimeline.test.js b/frontend/src/lib/features/items/ApprovalsTimeline.test.js index a5d8503..c9c5375 100644 --- a/frontend/src/lib/features/items/ApprovalsTimeline.test.js +++ b/frontend/src/lib/features/items/ApprovalsTimeline.test.js @@ -28,6 +28,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ }), })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/items/Comments.test.js b/frontend/src/lib/features/items/Comments.test.js index 50c8bb3..de818d3 100644 --- a/frontend/src/lib/features/items/Comments.test.js +++ b/frontend/src/lib/features/items/Comments.test.js @@ -7,6 +7,15 @@ const mocks = vi.hoisted(() => ({ getCannedResponses: vi.fn(), })); +const prefMocks = vi.hoisted(() => ({ + commentSortOrder: 'oldest', + setCommentSortOrder: vi.fn(), +})); + +vi.mock('../../stores/collectionPreferences.svelte.js', () => ({ + collectionPreferences: prefMocks, +})); + vi.mock('../../api/misc.js', () => ({ getComments: mocks.getComments, })) @@ -153,6 +162,58 @@ describe("Comments render window (WI-1451)", () => { }); }); +describe("Comments sort preference", () => { + function makeComment(id, minutesOffset) { + return { + id, + author_id: 8, + author_name: "Author", + content: `Comment body ${id}`, + created_at: new Date(Date.UTC(2026, 7, 17, 8, minutesOffset)).toISOString(), + updated_at: new Date(Date.UTC(2026, 7, 17, 8, minutesOffset)).toISOString(), + source: "user", + is_agent: false, + is_private: false, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + prefMocks.commentSortOrder = 'oldest'; + }); + + it("renders newest first when the stored preference is newest", async () => { + prefMocks.commentSortOrder = 'newest'; + mocks.getComments.mockResolvedValue({ + comments: [makeComment(1, 0), makeComment(2, 1), makeComment(3, 2)], + total: 3, + has_more: false, + }); + + render(Comments, { props: { itemId: 42 } }); + await waitFor(() => expect(screen.getAllByTestId("comment-item").length).toBe(3)); + + const ids = screen + .getAllByTestId("comment-item") + .map((element) => element.getAttribute("data-comment-id")); + expect(ids).toEqual(["3", "2", "1"]); + }); + + it("persists the toggled order through the preferences store", async () => { + mocks.getComments.mockResolvedValue({ + comments: [makeComment(1, 0), makeComment(2, 1)], + total: 2, + has_more: false, + }); + + render(Comments, { props: { itemId: 42 } }); + const toggle = await screen.findByTestId("comments-sort-toggle"); + await fireEvent.click(toggle); + + expect(prefMocks.setCommentSortOrder).toHaveBeenCalledWith("newest"); + }); +}); + describe("Comments canned response visibility", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/frontend/src/lib/features/items/CustomFieldRenderer.test.js b/frontend/src/lib/features/items/CustomFieldRenderer.test.js index c5e76f8..43779f3 100644 --- a/frontend/src/lib/features/items/CustomFieldRenderer.test.js +++ b/frontend/src/lib/features/items/CustomFieldRenderer.test.js @@ -7,6 +7,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; // to a controllable spy so we can drive the user-lookup branch. vi.mock('../../api/users.js', () => ({ getUsers: vi.fn(), + getAssignableUsers: vi.fn(), })) vi.mock('../../api/assets.js', () => ({ @@ -40,7 +41,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ // which jsdom doesn't implement. The renderer itself doesn't use // transitions, but a child picker might pull one in transitively. Defensive // stub. -import { getUsers } from '../../api/users.js'; +import { getUsers, getAssignableUsers } from '../../api/users.js'; import { referenceDisplayCache } from '../../stores/referenceDisplayCache.svelte.js'; import CustomFieldRenderer from './CustomFieldRenderer.svelte'; @@ -436,6 +437,42 @@ describe('user field', () => { expect(screen.getByText('common.unknownUser')).toBeInTheDocument(); }); }); + + // GH #420: an editable user custom field is a free picker. With a known + // workspace it must use the workspace-scoped roster, which non-admins can + // read, instead of the global directory that 403s for them. + test('editable picker loads the workspace roster when a workspaceId is given', async () => { + getAssignableUsers.mockResolvedValue([ + { id: 3, first_name: 'Casey', last_name: 'Doe', username: 'cdoe' }, + ]); + render(CustomFieldRenderer, { + props: { + field: { field_type: 'user', name: 'Owner' }, + value: null, + readonly: false, + workspaceId: 5, + }, + }); + + await waitFor(() => expect(getAssignableUsers).toHaveBeenCalledWith(5)); + expect(getUsers).not.toHaveBeenCalled(); + }); + + test('editable picker keeps caller-supplied options', async () => { + render(CustomFieldRenderer, { + props: { + field: { field_type: 'user', name: 'Owner' }, + value: null, + readonly: false, + workspaceId: 5, + users: [{ id: 9, first_name: 'Provided', last_name: 'User', username: 'puser' }], + }, + }); + + await Promise.resolve(); + expect(getAssignableUsers).not.toHaveBeenCalled(); + expect(getUsers).not.toHaveBeenCalled(); + }); }); describe('milestone field', () => { diff --git a/frontend/src/lib/features/items/DynamicFieldFilter.test.js b/frontend/src/lib/features/items/DynamicFieldFilter.test.js index 4362581..f08ec38 100644 --- a/frontend/src/lib/features/items/DynamicFieldFilter.test.js +++ b/frontend/src/lib/features/items/DynamicFieldFilter.test.js @@ -269,4 +269,39 @@ describe('DynamicFieldFilter value help', () => { expect(onchange).toHaveBeenLastCalledWith(expect.objectContaining({ value: 2, values: [] })); }); + + test('lists custom fields that share a name without duplicate option keys', async () => { + getCatalog.mockResolvedValueOnce({ + fields: [ + { + name: 'cfid_50', + label: 'Duplicate', + field_type: 'text', + value_type: 'string', + aliases: ['cf_Duplicate', 'custom.Duplicate'], + operators: ['=', '!=', '~', 'IS NULL', 'IS NOT NULL'], + }, + { + name: 'cfid_51', + label: 'Duplicate', + field_type: 'text', + value_type: 'string', + aliases: ['cf_Duplicate', 'custom.Duplicate'], + operators: ['=', '!=', '~', 'IS NULL', 'IS NOT NULL'], + }, + ], + }); + + render(DynamicFieldFilter, { + props: { + filter: { field: null, operator: '=', value: '', values: [] }, + testIdPrefix: 'duplicate-name-filter', + }, + }); + + await fireEvent.click(await screen.findByTestId('field-selector-trigger')); + + expect(await screen.findByTestId('field-option-cfid_50')).toBeInTheDocument(); + expect(await screen.findByTestId('field-option-cfid_51')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/features/items/ItemDetail.test.js b/frontend/src/lib/features/items/ItemDetail.test.js index 360e16e..1141384 100644 --- a/frontend/src/lib/features/items/ItemDetail.test.js +++ b/frontend/src/lib/features/items/ItemDetail.test.js @@ -126,6 +126,7 @@ vi.mock('../../stores/attachmentStatus.svelte.js', async () => { vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), addToast: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), @@ -149,8 +150,14 @@ vi.mock('../../composables/useItemAttachments.svelte.js', () => ({ load: vi.fn().mockResolvedValue(undefined), }), })); +const itemEventStream = vi.hoisted(() => ({ onReconcile: null, onDeleted: null })); + vi.mock('../../composables/useItemEventStream.svelte.js', () => ({ - useItemEventStream: () => ({ connected: false }), + useItemEventStream: (_getItemId, handlers = {}) => { + itemEventStream.onReconcile = handlers.onReconcile ?? null; + itemEventStream.onDeleted = handlers.onDeleted ?? null; + return { connected: false }; + }, })); vi.mock('../../utils/contextCommands.js', () => ({ registerContextCommands: vi.fn(), @@ -454,3 +461,51 @@ describe('ItemDetail time log modal rendering', () => { ); }); }); + +describe('ItemDetail background reconcile', () => { + beforeEach(() => { + itemDetailStore.reset(); + workspaceDataStore.reset(); + navigate.mockClear(); + itemEventStream.onReconcile = null; + mockSuccessfulRelatedLoads(); + items.getDetailSummaryByKey.mockResolvedValue(detailSummary(summaryItem(1))); + }); + + it('does not dim the item view while a background SSE reconcile is in flight', async () => { + render(ItemDetail, { + props: { + workspaceKey: 'WI', + itemNumber: 1, + itemId: 1, + workspaceId: 1, + canonicalizeKeyRoute: false, + tab: 'comments', + }, + }); + + await waitFor(() => expect(screen.getByTestId('item-detail-ready')).toBeInTheDocument()); + const view = screen.getByTestId('item-detail-ready').closest('.transition-opacity'); + expect(view).not.toHaveClass('opacity-90'); + + // Hold the reconcile's summary open so the in-flight render is observable. + let release; + items.getDetailSummaryByKey.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }) + ); + const reconcile = itemEventStream.onReconcile(); + await waitFor(() => expect(items.getDetailSummaryByKey).toHaveBeenCalledTimes(2)); + + expect(screen.getByTestId('item-detail-ready').closest('.transition-opacity')).not.toHaveClass( + 'opacity-90' + ); + + release(detailSummary(summaryItem(1))); + await reconcile; + expect(screen.getByTestId('item-detail-ready').closest('.transition-opacity')).not.toHaveClass( + 'opacity-90' + ); + }); +}); diff --git a/frontend/src/lib/features/items/ItemDetailDescription.test.js b/frontend/src/lib/features/items/ItemDetailDescription.test.js index d722b04..eaa7891 100644 --- a/frontend/src/lib/features/items/ItemDetailDescription.test.js +++ b/frontend/src/lib/features/items/ItemDetailDescription.test.js @@ -18,6 +18,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); @@ -70,3 +71,67 @@ describe('ItemDetailDescription click-to-edit', () => { expect(onstartEditingDescription).toHaveBeenCalledTimes(1); }); }); + +describe('ItemDetailDescription attachment visibility toggle', () => { + const attachment = { + id: 5, + original_filename: 'evidence.png', + mime_type: 'image/png', + file_size: 10, + is_internal: false, + }; + + it('offers the toggle to an editor on a customer request', () => { + renderDescription({ + item: { id: 42, workspace_id: 7, request_type_id: 3 }, + canEdit: true, + attachments: [attachment], + }); + + expect(screen.getByTestId('item-attachment-visibility-5')).toBeInTheDocument(); + }); + + it('offers the toggle when the requester is a portal customer', () => { + renderDescription({ + item: { id: 42, workspace_id: 7, creator_portal_customer_id: 9 }, + canEdit: true, + attachments: [attachment], + }); + + expect(screen.getByTestId('item-attachment-visibility-5')).toBeInTheDocument(); + }); + + it('does not offer the toggle on an agent-created item', () => { + renderDescription({ + item: { id: 42, workspace_id: 7 }, + canEdit: true, + attachments: [attachment], + }); + + expect(screen.queryByTestId('item-attachment-visibility-5')).not.toBeInTheDocument(); + }); + + it('does not offer the toggle to a read-only viewer', () => { + renderDescription({ + item: { id: 42, workspace_id: 7, request_type_id: 3 }, + canEdit: false, + attachments: [attachment], + }); + + expect(screen.queryByTestId('item-attachment-visibility-5')).not.toBeInTheDocument(); + }); + + it('forwards the toggle to the visibility handler', async () => { + const onattachmentToggleVisibility = vi.fn(); + renderDescription({ + item: { id: 42, workspace_id: 7, request_type_id: 3 }, + canEdit: true, + attachments: [attachment], + onattachmentToggleVisibility, + }); + + await fireEvent.click(screen.getByTestId('item-attachment-visibility-5')); + + expect(onattachmentToggleVisibility).toHaveBeenCalledWith(attachment); + }); +}); diff --git a/frontend/src/lib/features/items/ItemIncidentPanel.test.js b/frontend/src/lib/features/items/ItemIncidentPanel.test.js index 4e65935..8fe80d1 100644 --- a/frontend/src/lib/features/items/ItemIncidentPanel.test.js +++ b/frontend/src/lib/features/items/ItemIncidentPanel.test.js @@ -16,6 +16,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/items/ItemSLAPanel.test.js b/frontend/src/lib/features/items/ItemSLAPanel.test.js index 4728ac8..0b4e3f0 100644 --- a/frontend/src/lib/features/items/ItemSLAPanel.test.js +++ b/frontend/src/lib/features/items/ItemSLAPanel.test.js @@ -3,7 +3,7 @@ import { tick } from 'svelte'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../api/sla.js', () => ({ - sla: { getItemSLA: vi.fn() }, + sla: { getItemSLA: vi.fn(), getItemSLABatch: vi.fn() }, })); vi.mock('../../stores/i18n.svelte.js', () => ({ @@ -15,6 +15,7 @@ vi.mock('../../utils/dateFormatter.js', () => ({ })); import { sla } from '../../api/sla.js'; +import { resetSLAState } from '../sla/slaState.js'; import ItemSLAPanel from './ItemSLAPanel.svelte'; const stateWithCycle = [ @@ -52,13 +53,14 @@ function deferred() { describe('ItemSLAPanel', () => { beforeEach(() => { vi.clearAllMocks(); + resetSLAState(); }); it('hides itself when the item has no SLA cycles', async () => { sla.getItemSLA.mockResolvedValue([]); render(ItemSLAPanel, { itemId: 42 }); - await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(42)); + await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(42, expect.anything())); expect(screen.queryByTestId('item-sla-panel')).toBeNull(); }); @@ -142,6 +144,15 @@ describe('ItemSLAPanel', () => { expect(await screen.findByText(/Asia\/Tokyo numeric:2-digit/)).toBeTruthy(); }); + it('reads through the shared workspace batch so badges and the panel share one request', async () => { + sla.getItemSLABatch.mockResolvedValue({ 42: stateWithCycle }); + render(ItemSLAPanel, { itemId: 42, workspaceId: 5 }); + + expect(await screen.findByTestId('item-sla-panel')).toBeTruthy(); + expect(sla.getItemSLABatch).toHaveBeenCalledWith(5, [42], expect.anything()); + expect(sla.getItemSLA).not.toHaveBeenCalled(); + }); + it('reloads when item mutations fire refresh-work-items', async () => { sla.getItemSLA.mockResolvedValue(stateWithCycle); render(ItemSLAPanel, { itemId: 42 }); @@ -150,7 +161,28 @@ describe('ItemSLAPanel', () => { sla.getItemSLA.mockClear(); window.dispatchEvent(new CustomEvent('refresh-work-items')); - await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(42)); + await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(42, expect.anything())); + }); + + it('ignores a burst that names another workspace', async () => { + sla.getItemSLA.mockResolvedValue(stateWithCycle); + render(ItemSLAPanel, { itemId: 42, workspaceId: 5 }); + await screen.findByTestId('item-sla-panel'); + + sla.getItemSLA.mockClear(); + vi.useFakeTimers(); + try { + window.dispatchEvent( + new CustomEvent('refresh-work-items', { + detail: { itemId: 42, item: { id: 42, workspace_id: 99 } }, + }), + ); + + await vi.advanceTimersByTimeAsync(20); + expect(sla.getItemSLA).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it('refreshes an ongoing timer on an interval without item mutations', async () => { @@ -196,9 +228,9 @@ describe('ItemSLAPanel', () => { sla.getItemSLA.mockImplementation((id) => (id === 1 ? first.promise : second.promise)); const { rerender } = render(ItemSLAPanel, { props: { itemId: 1 } }); - await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(1)); + await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(1, expect.anything())); await rerender({ itemId: 2 }); - await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(2)); + await waitFor(() => expect(sla.getItemSLA).toHaveBeenCalledWith(2, expect.anything())); second.resolve([{ ...stateWithCycle[0], metric_name: 'Newer metric' }]); expect(await screen.findByText('Newer metric')).toBeTruthy(); diff --git a/frontend/src/lib/features/items/ParticipantsSection.test.js b/frontend/src/lib/features/items/ParticipantsSection.test.js index abdb09d..34b31d9 100644 --- a/frontend/src/lib/features/items/ParticipantsSection.test.js +++ b/frontend/src/lib/features/items/ParticipantsSection.test.js @@ -18,6 +18,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/items/SidebarDateField.test.js b/frontend/src/lib/features/items/SidebarDateField.test.js index ffeab48..34ebaa0 100644 --- a/frontend/src/lib/features/items/SidebarDateField.test.js +++ b/frontend/src/lib/features/items/SidebarDateField.test.js @@ -7,6 +7,11 @@ import SidebarDateField from './SidebarDateField.svelte'; afterEach(cleanup); +async function startEditing(fieldKey = 'due_date') { + await fireEvent.click(screen.getByTestId(`sidebar-date-${fieldKey.replaceAll('_', '-')}`)); + return screen.findByTestId(`sidebar-date-${fieldKey.replaceAll('_', '-')}-input`); +} + describe('SidebarDateField', () => { it('displays a date and starts editing when enabled', async () => { const onStartEdit = vi.fn(); @@ -23,28 +28,47 @@ describe('SidebarDateField', () => { const field = screen.getByTestId('sidebar-date-due-date'); expect(field).toHaveTextContent('Due date'); expect(field).not.toHaveTextContent('common.none'); + await fireEvent.click(field); expect(onStartEdit).toHaveBeenCalledOnce(); + expect(await screen.findByTestId('sidebar-date-due-date-input')).toBeInTheDocument(); }); - it('saves changed and cleared values', async () => { + it('saves a changed value and closes the editor', async () => { const onSave = vi.fn(); render(SidebarDateField, { props: { fieldKey: 'start_date', label: 'Start date', value: '2026-09-12T00:00:00Z', - editing: true, editable: true, onSave, }, }); - const input = screen.getByTestId('sidebar-date-start-date-input'); + const input = await startEditing('start_date'); await fireEvent.change(input, { target: { value: '2026-09-15' } }); + + expect(onSave).toHaveBeenCalledWith('2026-09-15'); + expect(screen.queryByTestId('sidebar-date-start-date-input')).not.toBeInTheDocument(); + }); + + it('saves a cleared value', async () => { + const onSave = vi.fn(); + render(SidebarDateField, { + props: { + fieldKey: 'due_date', + label: 'Due date', + value: '2026-09-12T00:00:00Z', + editable: true, + onSave, + }, + }); + + const input = await startEditing('due_date'); await fireEvent.change(input, { target: { value: '' } }); - expect(onSave).toHaveBeenNthCalledWith(1, '2026-09-15'); - expect(onSave).toHaveBeenNthCalledWith(2, null); + + expect(onSave).toHaveBeenCalledWith(null); }); it('cancels editing when clicking outside', async () => { @@ -53,14 +77,16 @@ describe('SidebarDateField', () => { props: { fieldKey: 'end_date', label: 'End date', - editing: true, editable: true, onCancel, }, }); + await startEditing('end_date'); await fireEvent.click(document.body); + expect(onCancel).toHaveBeenCalledOnce(); + expect(screen.queryByTestId('sidebar-date-end-date-input')).not.toBeInTheDocument(); }); it('does not start editing when disabled', async () => { diff --git a/frontend/src/lib/features/items/SidebarStoryPointsField.test.js b/frontend/src/lib/features/items/SidebarStoryPointsField.test.js index 78f54e6..67b5576 100644 --- a/frontend/src/lib/features/items/SidebarStoryPointsField.test.js +++ b/frontend/src/lib/features/items/SidebarStoryPointsField.test.js @@ -51,6 +51,8 @@ describe('SidebarStoryPointsField', () => { const input = await startEditing(); Object.defineProperty(input, 'validity', { value: { badInput: true } }); + // Typing invalid text into a number input fires `input` with badInput set. + await fireEvent.input(input, { target: { value: '' } }); await fireEvent.blur(input); expect(onSave).not.toHaveBeenCalled(); diff --git a/frontend/src/lib/features/items/TodoList.test.js b/frontend/src/lib/features/items/TodoList.test.js index 7e5ced7..0816414 100644 --- a/frontend/src/lib/features/items/TodoList.test.js +++ b/frontend/src/lib/features/items/TodoList.test.js @@ -27,7 +27,7 @@ vi.mock('../../stores/auth.svelte.js', () => ({ authStore: { currentUser: { id: 7 } }, })); -vi.mock('../../stores/toasts.svelte.js', () => ({ errorToast: vi.fn() })); +vi.mock('../../stores/toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: vi.fn() })); vi.mock('@lucide/svelte', () => ({ Check: vi.fn(() => null), diff --git a/frontend/src/lib/features/iterations/IterationBurndownStub.svelte b/frontend/src/lib/features/iterations/IterationBurndownStub.svelte new file mode 100644 index 0000000..108fc43 --- /dev/null +++ b/frontend/src/lib/features/iterations/IterationBurndownStub.svelte @@ -0,0 +1,6 @@ + + +
diff --git a/frontend/src/lib/features/iterations/IterationDetail.test.js b/frontend/src/lib/features/iterations/IterationDetail.test.js new file mode 100644 index 0000000..729d0e8 --- /dev/null +++ b/frontend/src/lib/features/iterations/IterationDetail.test.js @@ -0,0 +1,108 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../../api/milestones.js', () => ({ + iterations: { + getProgress: vi.fn(), + get: vi.fn(), + getBurndown: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getAll: vi.fn(), + complete: vi.fn(), + }, +})); + +vi.mock('../../router.js', () => ({ navigate: vi.fn() })); + +vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), + errorToast: vi.fn(), + successToast: vi.fn(), +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ + i18n: { locale: 'en' }, + t: (key) => key, +})); + +vi.mock('../../stores/permissions.svelte.js', async () => { + const { writable } = await import('svelte/store'); + return { + permissionStore: writable({ userPermissionKeys: new Set() }), + isSystemAdmin: writable(false), + }; +}); + +vi.mock('../../stores/workspacePermissions.svelte.js', () => ({ + workspacePermissions: { + canAdminWorkspace: () => false, + hasPermission: () => false, + }, +})); + +vi.mock('../../stores/aiStore.svelte.js', () => ({ + aiStore: { available: false }, +})); + +vi.mock('../../composables/useConfirm.js', () => ({ confirm: vi.fn() })); + +vi.mock('./IterationBurndown.svelte', async () => ({ + default: (await import('./IterationBurndownStub.svelte')).default, +})); + +import { iterations } from '../../api/milestones.js'; +import IterationDetail from './IterationDetail.svelte'; + +const PROGRESS = { + iteration_name: 'Sprint 1', + description: '', + status: 'active', + start_date: '2026-01-01', + end_date: '2026-01-14', + is_global: true, + workspace_id: null, + total_items: 0, + completed_items: 0, + percent_complete: 0, + status_breakdown: [], + items_by_category: {}, +}; + +const ITERATION = { + id: 1, + name: 'Sprint 1', + status: 'active', + is_global: true, + workspace_id: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + iterations.getProgress.mockResolvedValue({ ...PROGRESS }); + iterations.get.mockResolvedValue({ ...ITERATION }); + iterations.getBurndown.mockResolvedValue({ points: [] }); +}); + +afterEach(cleanup); + +test('renders the iteration header and burndown through the shared layout', async () => { + render(IterationDetail, { iterationId: 1, workspaceId: 1 }); + + expect(await screen.findByText('Sprint 1')).toBeInTheDocument(); + expect(screen.getByText('iterations.status.active')).toBeInTheDocument(); + expect(screen.getByTestId('iteration-burndown')).toHaveAttribute('data-has-data', 'true'); +}); + +test('shows the retry action when the load fails and reloads on demand', async () => { + iterations.getProgress.mockRejectedValueOnce(new Error('nope')); + + render(IterationDetail, { iterationId: 1, workspaceId: 1 }); + + expect(await screen.findByText('nope')).toBeInTheDocument(); + + await fireEvent.click(screen.getByText('common.retry')); + + await waitFor(() => expect(screen.getByText('Sprint 1')).toBeInTheDocument()); + expect(iterations.getProgress).toHaveBeenCalledTimes(2); +}); diff --git a/frontend/src/lib/features/logbook/DocumentList.test.js b/frontend/src/lib/features/logbook/DocumentList.test.js deleted file mode 100644 index 36f6beb..0000000 --- a/frontend/src/lib/features/logbook/DocumentList.test.js +++ /dev/null @@ -1,196 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; -import { tick } from "svelte"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { logbook } from '../../api/logbook.js'; - - -vi.mock("../../router.js", () => ({ navigate: vi.fn() })); -vi.mock("../../stores/i18n.svelte.js", () => ({ t: (key) => key })); -vi.mock("../../stores/toasts.svelte.js", () => ({ - successToast: vi.fn(), - errorToast: vi.fn(), -})); -vi.mock("../../composables/useConfirm.js", () => ({ confirm: vi.fn() })); -vi.mock('../../api/logbook.js', () => ({ - logbook: { - keywordSearch: vi.fn(), - getDocumentThumbnailUrl: (id) => `/thumb/${id}`, - getDocumentFileUrl: (id) => `/file/${id}`, - createNote: vi.fn(), - archiveDocument: vi.fn(), - }, -})); -vi.mock("../../stores/logbook.svelte.js", () => ({ - logbookStore: { - documents: [], - totalDocuments: 2, - documentsLoading: false, - buckets: [], - loadDocuments: vi.fn(), - loadAllDocuments: vi.fn(), - }, -})); - - -const { logbookStore } = await import("../../stores/logbook.svelte.js"); -const { default: DocumentList } = await import("./DocumentList.svelte"); - -function deferred() { - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function twoReadyDocuments() { - return [ - { id: 1, title: "Alpha", status: "ready", source_type: "upload" }, - { id: 2, title: "Beta", status: "ready", source_type: "note" }, - ]; -} - -async function typeSearch(value) { - await fireEvent.input(screen.getByTestId("logbook-search"), { - target: { value }, - }); -} - -describe("DocumentList search ownership (WI-1616)", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - logbookStore.documents = twoReadyDocuments(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("keeps the newest query results when an older search resolves last", async () => { - const slow = deferred(); - const fast = deferred(); - logbook.keywordSearch - .mockImplementationOnce(() => slow.promise) - .mockImplementationOnce(() => fast.promise); - - render(DocumentList, { props: { activeBucketId: null } }); - - await typeSearch("alpha"); - await vi.advanceTimersByTimeAsync(300); - await typeSearch("beta"); - await vi.advanceTimersByTimeAsync(300); - - fast.resolve({ data: [{ document_id: 2 }] }); - await vi.advanceTimersByTimeAsync(0); - await tick(); - expect(screen.getByTestId("logbook-document-2")).toBeInTheDocument(); - expect(screen.queryByTestId("logbook-document-1")).not.toBeInTheDocument(); - - // The obsolete first search resolves last and must be discarded. - slow.resolve({ data: [{ document_id: 1 }] }); - await vi.advanceTimersByTimeAsync(0); - await tick(); - expect(screen.queryByTestId("logbook-document-1")).not.toBeInTheDocument(); - expect(screen.getByTestId("logbook-document-2")).toBeInTheDocument(); - }); - - it("clears the filter immediately when the query is emptied", async () => { - logbook.keywordSearch.mockResolvedValueOnce({ data: [{ document_id: 2 }] }); - render(DocumentList, { props: { activeBucketId: null } }); - - await typeSearch("beta"); - await vi.advanceTimersByTimeAsync(300); - await tick(); - expect(screen.getByTestId("logbook-document-2")).toBeInTheDocument(); - expect(screen.queryByTestId("logbook-document-1")).not.toBeInTheDocument(); - - await typeSearch(""); - await tick(); - expect(screen.getByTestId("logbook-document-1")).toBeInTheDocument(); - expect(screen.getByTestId("logbook-document-2")).toBeInTheDocument(); - }); - - it("drops an in-flight search when the bucket changes", async () => { - const slow = deferred(); - logbook.keywordSearch.mockImplementationOnce(() => slow.promise); - - const { rerender } = render(DocumentList, { props: { activeBucketId: 1 } }); - await typeSearch("beta"); - await vi.advanceTimersByTimeAsync(300); - - await rerender({ activeBucketId: 2 }); - await tick(); - - slow.resolve({ data: [{ document_id: 2 }] }); - await tick(); - - expect(screen.getByTestId("logbook-document-1")).toBeInTheDocument(); - expect(screen.getByTestId("logbook-document-2")).toBeInTheDocument(); - }); - - it("does not start a search when unmounted before the debounce fires", async () => { - const { unmount } = render(DocumentList, { props: { activeBucketId: null } }); - await typeSearch("beta"); - unmount(); - await vi.advanceTimersByTimeAsync(300); - - expect(logbook.keywordSearch).not.toHaveBeenCalled(); - }); -}); - -describe("DocumentList processing refresh scheduling (WI-1617)", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - logbookStore.documents = [ - { id: 1, title: "Alpha", status: "processing", source_type: "upload" }, - ]; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("never overlaps refreshes and stops polling on unmount", async () => { - const first = deferred(); - logbookStore.loadAllDocuments.mockReturnValueOnce(first.promise); - - const { unmount } = render(DocumentList, { props: { activeBucketId: null } }); - - await vi.advanceTimersByTimeAsync(3000); - expect(logbookStore.loadAllDocuments).toHaveBeenCalledTimes(1); - - // The first refresh is still running, so the next tick must not overlap. - await vi.advanceTimersByTimeAsync(3000); - expect(logbookStore.loadAllDocuments).toHaveBeenCalledTimes(1); - - first.resolve({ data: [], pagination: { total: 0 } }); - await tick(); - await vi.advanceTimersByTimeAsync(3000); - expect(logbookStore.loadAllDocuments).toHaveBeenCalledTimes(2); - - unmount(); - await vi.advanceTimersByTimeAsync(9000); - expect(logbookStore.loadAllDocuments).toHaveBeenCalledTimes(2); - }); - - it("skips a scheduled refresh while the page is hidden", async () => { - const hiddenDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, "hidden"); - Object.defineProperty(document, "hidden", { configurable: true, value: true }); - - try { - render(DocumentList, { props: { activeBucketId: null } }); - await vi.advanceTimersByTimeAsync(3000); - expect(logbookStore.loadAllDocuments).not.toHaveBeenCalled(); - } finally { - if (hiddenDescriptor) { - Object.defineProperty(Document.prototype, "hidden", hiddenDescriptor); - } else { - delete document.hidden; - } - } - }); -}); diff --git a/frontend/src/lib/features/milestones/MilestoneDetail.test.js b/frontend/src/lib/features/milestones/MilestoneDetail.test.js index 842bc75..b36ab5d 100644 --- a/frontend/src/lib/features/milestones/MilestoneDetail.test.js +++ b/frontend/src/lib/features/milestones/MilestoneDetail.test.js @@ -2,17 +2,21 @@ import { cleanup, render, screen, within } from '@testing-library/svelte'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; vi.mock('../../api/milestones.js', () => ({ + milestoneCategories: { getAll: vi.fn().mockResolvedValue([]) }, milestones: { getProgress: vi.fn(), get: vi.fn(), update: vi.fn(), delete: vi.fn(), + getComments: vi.fn().mockResolvedValue({ comments: [], has_more: false }), + getPageLinks: vi.fn().mockResolvedValue([]), }, })); vi.mock('../../router.js', () => ({ navigate: vi.fn() })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/milestones/planningLists.test.js b/frontend/src/lib/features/milestones/planningLists.test.js index 1adf102..e6fd0ba 100644 --- a/frontend/src/lib/features/milestones/planningLists.test.js +++ b/frontend/src/lib/features/milestones/planningLists.test.js @@ -81,19 +81,37 @@ beforeEach(() => { }); afterEach(cleanup); -describe.each([ - ["milestones", Milestones], - ["iterations", Iterations], -])("%s list scope", (_name, Component) => { +describe("milestones list scope", () => { + it("shows global and accessible workspace entries on the global list", async () => { + render(Milestones); + await screen.findByText("Shared release"); + expect(screen.getByText("Workspace release")).toBeInTheDocument(); + expect(mocks.getAll).toHaveBeenCalledWith({}); + }); + + it("includes local and global entries in a workspace", async () => { + render(Milestones, { workspaceId: 23 }); + await screen.findByText("Workspace release"); + expect(screen.getByText("Shared release")).toBeInTheDocument(); + await waitFor(() => + expect(mocks.getAll).toHaveBeenCalledWith({ + workspace_id: 23, + include_global: true, + }), + ); + }); +}); + +describe("iterations list scope", () => { it("shows only global entries on the global list", async () => { - render(Component); + render(Iterations); await screen.findByText("Shared release"); expect(screen.queryByText("Workspace release")).not.toBeInTheDocument(); expect(mocks.getAll).toHaveBeenCalledWith({ is_global: true }); }); it("includes local and global entries in a workspace", async () => { - render(Component, { workspaceId: 23 }); + render(Iterations, { workspaceId: 23 }); await screen.findByText("Workspace release"); expect(screen.getByText("Shared release")).toBeInTheDocument(); await waitFor(() => diff --git a/frontend/src/lib/features/pages/PageMoveDialog.test.js b/frontend/src/lib/features/pages/PageMoveDialog.test.js new file mode 100644 index 0000000..f8c743d --- /dev/null +++ b/frontend/src/lib/features/pages/PageMoveDialog.test.js @@ -0,0 +1,87 @@ +import { fireEvent, render } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../../api/workspaces.js', () => ({ + workspaces: { + getPage: vi.fn(), + get: vi.fn(), + getOrCreatePersonal: vi.fn(), + }, +})); + +vi.mock('../../api/pages.js', () => ({ + pages: { + getAll: vi.fn(() => Promise.resolve([])), + movePage: vi.fn(), + }, +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import { workspaces } from '../../api/workspaces.js'; +import { workspacesStore } from '../../stores/workspaces.svelte.js'; +import PageMoveDialog from './PageMoveDialog.svelte'; + +function pageDocument(rows, total = rows.length) { + return { data: rows, pagination: { page: 1, page_size: 200, total, total_pages: 1 } }; +} + +beforeEach(() => { + workspacesStore.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; +}); + +test('searches workspaces on the server so a destination beyond the cached page is reachable', async () => { + // The directory cache only holds one bounded page; the destination here is + // deliberately absent from it and only reachable through searchWorkspaces. + workspaces.getPage.mockImplementation((filters) => { + const search = filters?.search || ''; + if (!search) { + return Promise.resolve( + pageDocument([ + { id: 1, name: 'Alpha', is_personal: false, active: true }, + { id: 2, name: 'Beta', is_personal: false, active: true }, + ]) + ); + } + const matches = + search.toLowerCase() === 'zeta' + ? [{ id: 387, name: 'Zeta destination', is_personal: false, active: true }] + : []; + return Promise.resolve(pageDocument(matches, matches.length)); + }); + + render(PageMoveDialog, { + props: { + isOpen: true, + workspaceId: 1, + page: { id: 50, title: 'Portable handbook', parent_id: null }, + }, + }); + + // Flush the cached directory load and the candidate fetch. + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(0); + + const input = document.querySelector('#page-move-workspace-picker'); + await fireEvent.click(input); + await fireEvent.input(input, { target: { value: 'zeta' } }); + + // Debounce (300ms), then the server search replaces the option list. + await vi.advanceTimersByTimeAsync(400); + await vi.advanceTimersByTimeAsync(0); + + expect(workspaces.getPage).toHaveBeenCalledWith(expect.objectContaining({ search: 'zeta' })); + expect( + document.querySelector('[data-testid="page-move-workspace-option-387"]') + ).toBeInTheDocument(); + expect( + document.querySelector('[data-testid="page-move-workspace-option-1"]') + ).not.toBeInTheDocument(); +}); diff --git a/frontend/src/lib/features/sla/SlaCalendarsTab.test.js b/frontend/src/lib/features/sla/SlaCalendarsTab.test.js index 31f3e95..933d1f4 100644 --- a/frontend/src/lib/features/sla/SlaCalendarsTab.test.js +++ b/frontend/src/lib/features/sla/SlaCalendarsTab.test.js @@ -15,6 +15,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/sla/SlaMetricsTab.test.js b/frontend/src/lib/features/sla/SlaMetricsTab.test.js index 1f251f2..bd3afa4 100644 --- a/frontend/src/lib/features/sla/SlaMetricsTab.test.js +++ b/frontend/src/lib/features/sla/SlaMetricsTab.test.js @@ -21,6 +21,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/sla/SlaRecalculationTab.test.js b/frontend/src/lib/features/sla/SlaRecalculationTab.test.js index c230159..d6e7b65 100644 --- a/frontend/src/lib/features/sla/SlaRecalculationTab.test.js +++ b/frontend/src/lib/features/sla/SlaRecalculationTab.test.js @@ -14,6 +14,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/sla/SlaTeamBindingsTab.test.js b/frontend/src/lib/features/sla/SlaTeamBindingsTab.test.js index de0c647..161eee4 100644 --- a/frontend/src/lib/features/sla/SlaTeamBindingsTab.test.js +++ b/frontend/src/lib/features/sla/SlaTeamBindingsTab.test.js @@ -19,6 +19,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/sla/SlaWarningThresholdsTab.test.js b/frontend/src/lib/features/sla/SlaWarningThresholdsTab.test.js index d4c3b3a..85a86d5 100644 --- a/frontend/src/lib/features/sla/SlaWarningThresholdsTab.test.js +++ b/frontend/src/lib/features/sla/SlaWarningThresholdsTab.test.js @@ -16,6 +16,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/features/sla/slaRefreshReconciliation.test.js b/frontend/src/lib/features/sla/slaRefreshReconciliation.test.js new file mode 100644 index 0000000..8c61b3c --- /dev/null +++ b/frontend/src/lib/features/sla/slaRefreshReconciliation.test.js @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../api/sla.js', () => ({ + sla: { + getItemSLA: vi.fn(), + getItemSLABatch: vi.fn(), + getWarningThresholds: vi.fn(), + }, +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ + t: (key, params) => (params ? `${key} ${JSON.stringify(params)}` : key), +})); + +vi.mock('../../utils/dateFormatter.js', () => ({ + formatInstant: (_value, timezone, options) => `${timezone} ${options.hour}:${options.minute}`, +})); + +import { sla } from '../../api/sla.js'; +import ItemSLAPanel from '../items/ItemSLAPanel.svelte'; +import SLABadge from './SLABadge.svelte'; +import { resetSLAState } from './slaState.js'; + +const state = [ + { + metric_id: 1, + metric_name: 'First response', + display_format: 'time', + recalculating: false, + ongoing: { + cycle_no: 1, + breached: false, + paused: false, + within_calendar_hours: true, + elapsed_ms: 1000, + goal_duration_ms: 3600000, + remaining_ms: 3599000, + }, + completed: [], + }, +]; + +describe('SLA refresh reconciliation', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSLAState(); + sla.getWarningThresholds.mockResolvedValue([]); + sla.getItemSLABatch.mockResolvedValue({ 42: state }); + }); + + it('shares one workspace batch between the panel and a badge', async () => { + render(ItemSLAPanel, { itemId: 42, workspaceId: 5 }); + render(SLABadge, { itemId: 42, workspaceId: 5 }); + + await screen.findByTestId('item-sla-panel'); + await vi.waitFor(() => expect(sla.getItemSLABatch).toHaveBeenCalledTimes(1)); + expect(sla.getItemSLABatch).toHaveBeenCalledWith(5, [42], expect.anything()); + expect(sla.getItemSLA).not.toHaveBeenCalled(); + }); + + it('reconciles the shared SLA surface once per refresh-work-items event', async () => { + render(ItemSLAPanel, { itemId: 42, workspaceId: 5 }); + render(SLABadge, { itemId: 42, workspaceId: 5 }); + await screen.findByTestId('item-sla-panel'); + await vi.waitFor(() => expect(sla.getItemSLABatch).toHaveBeenCalledTimes(1)); + + sla.getItemSLABatch.mockClear(); + window.dispatchEvent( + new CustomEvent('refresh-work-items', { + detail: { itemId: 42, item: { id: 42, workspace_id: 5 } }, + }), + ); + + await vi.waitFor(() => expect(sla.getItemSLABatch).toHaveBeenCalledTimes(1)); + expect(sla.getItemSLABatch).toHaveBeenCalledWith(5, [42], expect.anything()); + }); + + it('skips a burst that names another workspace', async () => { + render(ItemSLAPanel, { itemId: 42, workspaceId: 5 }); + render(SLABadge, { itemId: 42, workspaceId: 5 }); + await screen.findByTestId('item-sla-panel'); + await vi.waitFor(() => expect(sla.getItemSLABatch).toHaveBeenCalledTimes(1)); + + sla.getItemSLABatch.mockClear(); + vi.useFakeTimers(); + try { + window.dispatchEvent( + new CustomEvent('refresh-work-items', { + detail: { itemId: 42, item: { id: 42, workspace_id: 99 } }, + }), + ); + + await vi.advanceTimersByTimeAsync(20); + expect(sla.getItemSLABatch).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/frontend/src/lib/features/support/CannedResponsePicker.test.js b/frontend/src/lib/features/support/CannedResponsePicker.test.js index b234156..a7ac91d 100644 --- a/frontend/src/lib/features/support/CannedResponsePicker.test.js +++ b/frontend/src/lib/features/support/CannedResponsePicker.test.js @@ -16,6 +16,7 @@ vi.mock("../../stores/i18n.svelte.js", () => ({ })); vi.mock("../../stores/toasts.svelte.js", () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/support/SupportQueue.test.js b/frontend/src/lib/features/support/SupportQueue.test.js index 408c681..db42be0 100644 --- a/frontend/src/lib/features/support/SupportQueue.test.js +++ b/frontend/src/lib/features/support/SupportQueue.test.js @@ -76,6 +76,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/testing/TestRuns.test.js b/frontend/src/lib/features/testing/TestRuns.test.js index b3749fc..7bd4d95 100644 --- a/frontend/src/lib/features/testing/TestRuns.test.js +++ b/frontend/src/lib/features/testing/TestRuns.test.js @@ -24,6 +24,7 @@ vi.mock('../../api/tests/index.js', () => ({ }, })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: mocks.errorToast, warningToast: vi.fn(), })); diff --git a/frontend/src/lib/features/testing/TestSets.test.js b/frontend/src/lib/features/testing/TestSets.test.js index 07621e2..9590e2f 100644 --- a/frontend/src/lib/features/testing/TestSets.test.js +++ b/frontend/src/lib/features/testing/TestSets.test.js @@ -40,6 +40,7 @@ vi.mock('../../api/tests/index.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/features/time/TimeCustomers.test.js b/frontend/src/lib/features/time/TimeCustomers.test.js new file mode 100644 index 0000000..7595f52 --- /dev/null +++ b/frontend/src/lib/features/time/TimeCustomers.test.js @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { writable } from "svelte/store"; + +vi.mock("../../api/configuration.js", () => ({ + customFields: { getAll: vi.fn() }, +})); + +vi.mock("../../api/portal.js", () => ({ + customerOrganisations: { + getAll: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock("../../stores/attachmentStatus.svelte.js", () => ({ + attachmentStatus: writable({ enabled: false }), +})); + +vi.mock("../../stores/i18n.svelte.js", () => ({ + t: (key) => + ({ + "time.organizations.failedToDelete": "Failed to delete organization", + })[key] ?? key, +})); + +vi.mock("../../stores/toasts.svelte.js", () => ({ + surfaceError: vi.fn(), + errorToast: vi.fn(), +})); + +vi.mock("../../stores/permissions.svelte.js", () => ({ + permissionStore: writable({ userPermissionKeys: new Set(["customers.manage"]) }), + isSystemAdmin: writable(false), +})); + +vi.mock("../../composables/useConfirm.js", () => ({ + confirm: vi.fn(), +})); + +import { customFields } from "../../api/configuration.js"; +import { customerOrganisations } from "../../api/portal.js"; +import { errorToast } from "../../stores/toasts.svelte.js"; +import { confirm } from "../../composables/useConfirm.js"; +import TimeCustomers from "./TimeCustomers.svelte"; + +describe("TimeCustomers delete errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + customFields.getAll.mockResolvedValue([]); + customerOrganisations.getAll.mockResolvedValue([ + { id: 7, name: "Acme", active: true }, + ]); + }); + + it("surfaces a toast when deleting a linked organisation fails", async () => { + confirm.mockResolvedValue(true); + customerOrganisations.delete.mockRejectedValue( + new Error("Cannot delete customer with associated projects"), + ); + + render(TimeCustomers); + + const trigger = await screen.findByTestId("time-customer-actions-7"); + await fireEvent.click(trigger); + const deleteItem = await screen.findByTestId("time-customer-delete"); + await fireEvent.click(deleteItem); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + "Failed to delete organization: Cannot delete customer with associated projects", + ); + }); + }); + + it("does not call the API when the confirmation is dismissed", async () => { + confirm.mockResolvedValue(false); + + render(TimeCustomers); + + const trigger = await screen.findByTestId("time-customer-actions-7"); + await fireEvent.click(trigger); + const deleteItem = await screen.findByTestId("time-customer-delete"); + await fireEvent.click(deleteItem); + + await waitFor(() => { + expect(confirm).toHaveBeenCalled(); + }); + expect(customerOrganisations.delete).not.toHaveBeenCalled(); + expect(errorToast).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/features/workflows/SvelteFlowDesigner.test.js b/frontend/src/lib/features/workflows/SvelteFlowDesigner.test.js new file mode 100644 index 0000000..031810d --- /dev/null +++ b/frontend/src/lib/features/workflows/SvelteFlowDesigner.test.js @@ -0,0 +1,144 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// SvelteFlow cannot lay out edges in jsdom, so stand in for @xyflow/svelte and +// drive the same callbacks the library does: onedgeclick receives +// `{ event, edge }`, onconnect receives the connection params. +vi.mock('@xyflow/svelte', async () => { + const { default: SvelteFlow } = await import('./__stubs__/SvelteFlowStub.svelte'); + const { default: Noop } = await import('./__stubs__/Noop.svelte'); + return { + SvelteFlow, + Controls: Noop, + MiniMap: Noop, + Background: Noop, + Handle: Noop, + BaseEdge: Noop, + EdgeReconnectAnchor: Noop, + getSmoothStepPath: () => ['M0 0', 0, 0, 0, 0], + BackgroundVariant: { Dots: 'dots' }, + ConnectionMode: { Loose: 'loose' }, + Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, + addEdge: (edge, edges) => [...edges, edge], + }; +}); + +vi.mock('../../api/workflows.js', () => ({ + statuses: { create: vi.fn() }, +})); + +vi.mock('../../api/transitions.js', () => ({ + transitions: { governance: vi.fn(), setScreen: vi.fn() }, +})); + +vi.mock('../../api/configuration.js', () => ({ + screens: { + getAll: vi.fn(), + getAllWithFields: vi.fn(), + create: vi.fn(), + getFields: vi.fn(), + updateFields: vi.fn(), + }, + customFields: { getAll: vi.fn() }, +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); +vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), + errorToast: vi.fn(), + successToast: vi.fn(), +})); + +import { screens } from '../../api/configuration.js'; +import { transitions } from '../../api/transitions.js'; +import { successToast } from '../../stores/toasts.svelte.js'; +import { flowTestState } from './__stubs__/flowTestState.js'; +import SvelteFlowDesigner from './SvelteFlowDesigner.svelte'; + +const STATUSES = [ + { id: 1, name: 'Open', category_color: '#111111', category_name: 'To Do' }, + { id: 2, name: 'In Progress', category_color: '#222222', category_name: 'In Progress' }, + { id: 3, name: 'Done', category_color: '#333333', category_name: 'Done' }, +]; + +function makeWorkflow() { + return { + id: 7, + name: 'Delivery', + transitions: [ + { id: 101, workflow_id: 7, from_status_id: 1, to_status_id: 2, display_order: 0 }, + { id: 102, workflow_id: 7, from_status_id: 2, to_status_id: 3, display_order: 1 }, + ], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + flowTestState.connectPayload = null; + transitions.governance.mockResolvedValue({ screen: null }); + transitions.setScreen.mockResolvedValue({ screen: null }); + screens.getAllWithFields.mockResolvedValue([{ id: 55, name: 'Resolution' }]); +}); + +async function renderDesigner(overrides = {}) { + const props = { + workflow: makeWorkflow(), + statuses: STATUSES, + statusCategories: [], + onSave: vi.fn().mockResolvedValue([]), + onSaved: vi.fn(), + onCancel: vi.fn(), + ...overrides, + }; + render(SvelteFlowDesigner, { props }); + await waitFor(() => expect(screen.getByTestId('flow-edge-edge-1-2')).toBeInTheDocument()); + return props; +} + +describe('SvelteFlowDesigner transition inspector', () => { + test('opens the inspector when the library reports an edge click', async () => { + await renderDesigner(); + + await fireEvent.click(screen.getByTestId('flow-edge-edge-1-2')); + + await waitFor(() => expect(screen.getByTestId('transition-inspector')).toBeInTheDocument()); + expect(screen.getByTestId('transition-inspector-title')).toHaveTextContent( + 'Open → In Progress' + ); + expect(screen.queryByTestId('transition-inspector-pending')).toBeNull(); + }); + + test('stages a screen on a new transition and binds it after save', async () => { + const persisted = [ + { id: 101, from: { id: 1 }, to: { id: 2 }, from_all_statuses: false }, + { id: 102, from: { id: 2 }, to: { id: 3 }, from_all_statuses: false }, + { id: 999, from: { id: 1 }, to: { id: 3 }, from_all_statuses: false }, + ]; + const props = await renderDesigner({ onSave: vi.fn().mockResolvedValue(persisted) }); + + flowTestState.connectPayload = { + source: 'status-1', + target: 'status-3', + sourceHandle: 'right', + targetHandle: 'target-left', + }; + await fireEvent.click(screen.getByTestId('flow-connect')); + const newEdge = await screen.findByTestId('flow-edge-edge-1-3'); + + await fireEvent.click(newEdge); + await waitFor(() => expect(screen.getByTestId('transition-inspector')).toBeInTheDocument()); + expect(screen.getByTestId('transition-inspector-pending')).toBeInTheDocument(); + + await fireEvent.change(screen.getByTestId('transition-inspector-screen-picker'), { + target: { value: '55' }, + }); + await waitFor(() => expect(successToast).toHaveBeenCalled()); + expect(transitions.setScreen).not.toHaveBeenCalled(); + expect(screen.getByTestId('transition-inspector-screen-name')).toHaveTextContent('Resolution'); + + await fireEvent.click(screen.getByTestId('workflow-save')); + + await waitFor(() => expect(transitions.setScreen).toHaveBeenCalledWith(999, 55)); + expect(props.onSaved).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/features/workflows/TransitionInspector.test.js b/frontend/src/lib/features/workflows/TransitionInspector.test.js new file mode 100644 index 0000000..19092d8 --- /dev/null +++ b/frontend/src/lib/features/workflows/TransitionInspector.test.js @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../../api/transitions.js', () => ({ + transitions: { governance: vi.fn(), setScreen: vi.fn() }, +})); + +vi.mock('../../api/configuration.js', () => ({ + screens: { + getAll: vi.fn(), + getAllWithFields: vi.fn(), + create: vi.fn(), + getFields: vi.fn(), + updateFields: vi.fn(), + }, + customFields: { getAll: vi.fn() }, +})); + +vi.mock('../../stores/i18n.svelte.js', () => ({ t: (key) => key })); +vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), + errorToast: vi.fn(), + successToast: vi.fn(), +})); + +import { customFields, screens } from '../../api/configuration.js'; +import { transitions } from '../../api/transitions.js'; +import { screenEditorStore } from '../../stores/screenEditorStore.svelte.js'; +import TransitionInspector from './TransitionInspector.svelte'; + +const STATUSES = [ + { id: 1, name: 'Review' }, + { id: 2, name: 'Done' }, +]; + +const BOUND_SCREEN = { + id: 55, + name: 'Resolution', + field_count: 2, + required_field_count: 1, +}; + +beforeEach(() => { + vi.clearAllMocks(); + screenEditorStore.reset(); + screens.getAllWithFields.mockResolvedValue([BOUND_SCREEN]); + screens.getFields.mockResolvedValue([]); + screens.updateFields.mockResolvedValue([]); + customFields.getAll.mockResolvedValue([]); + transitions.setScreen.mockResolvedValue({ screen: null }); +}); + +function renderInspector(transition) { + return render(TransitionInspector, { + props: { transition, statuses: STATUSES, onClose: vi.fn(), onChanged: vi.fn() }, + }); +} + +describe('TransitionInspector field editor', () => { + test('opens the layout editor for the bound screen', async () => { + transitions.governance.mockResolvedValue({ screen: BOUND_SCREEN }); + renderInspector({ transitionId: 101, edgeId: 'edge-1-2', from_status_id: 1, to_status_id: 2 }); + + await screen.findByTestId('transition-inspector-screen-name'); + await fireEvent.click(screen.getByTestId('transition-inspector-edit-fields')); + + await waitFor(() => expect(screens.getFields).toHaveBeenCalledWith(BOUND_SCREEN.id)); + expect(await screen.findByTestId('screen-field-layout-editor')).toBeInTheDocument(); + }); + + test('opens the layout editor after creating and binding a new screen', async () => { + transitions.governance.mockResolvedValue({ screen: null }); + screens.create.mockResolvedValue({ id: 66, name: 'Escalation' }); + renderInspector({ transitionId: 101, edgeId: 'edge-1-2', from_status_id: 1, to_status_id: 2 }); + + await screen.findByTestId('transition-inspector-no-screen'); + await fireEvent.input(screen.getByTestId('transition-inspector-new-screen-name'), { + target: { value: 'Escalation' }, + }); + await fireEvent.click(screen.getByTestId('transition-inspector-create-screen')); + + await waitFor(() => expect(screens.create).toHaveBeenCalledWith({ name: 'Escalation' })); + await waitFor(() => expect(transitions.setScreen).toHaveBeenCalledWith(101, 66)); + await waitFor(() => expect(screens.getFields).toHaveBeenCalledWith(66)); + expect(await screen.findByTestId('screen-field-layout-editor')).toBeInTheDocument(); + }); + + test('stages the field counts for a screen picked on an unsaved edge', async () => { + const onChanged = vi.fn(); + screens.getAllWithFields.mockResolvedValue([ + { + id: 55, + name: 'Resolution', + fields: [ + { field_identifier: 'title', is_required: true }, + { field_identifier: 'priority', is_required: false }, + { field_identifier: 'description', is_required: false }, + ], + }, + ]); + render(TransitionInspector, { + props: { + transition: { edgeId: 'edge-1-2', from_status_id: 1, to_status_id: 2 }, + statuses: STATUSES, + onClose: vi.fn(), + onChanged, + }, + }); + + await screen.findByTestId('transition-inspector-no-screen'); + await fireEvent.change(screen.getByTestId('transition-inspector-screen-picker'), { + target: { value: '55' }, + }); + + await waitFor(() => + expect(onChanged).toHaveBeenCalledWith({ + edgeId: 'edge-1-2', + screen: { id: 55, name: 'Resolution', field_count: 3, required_field_count: 1 }, + }) + ); + }); +}); diff --git a/frontend/src/lib/features/workflows/WorkflowBuilder.test.js b/frontend/src/lib/features/workflows/WorkflowBuilder.test.js index ae51974..a7758cf 100644 --- a/frontend/src/lib/features/workflows/WorkflowBuilder.test.js +++ b/frontend/src/lib/features/workflows/WorkflowBuilder.test.js @@ -39,7 +39,7 @@ vi.mock('../../stores/i18n.svelte.js', () => ({ vi.mock('../../stores/permissions.svelte.js', () => ({ isSystemAdmin: writable(true), })); -vi.mock('../../stores/toasts.svelte.js', () => ({ errorToast: vi.fn() })); +vi.mock('../../stores/toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: vi.fn() })); vi.mock('../../composables/useConfirm.js', () => ({ confirm: vi.fn() })); import { objectTranslations } from '../../api/objectTranslations.js'; diff --git a/frontend/src/lib/features/workflows/__stubs__/Noop.svelte b/frontend/src/lib/features/workflows/__stubs__/Noop.svelte new file mode 100644 index 0000000..6466f5d --- /dev/null +++ b/frontend/src/lib/features/workflows/__stubs__/Noop.svelte @@ -0,0 +1,5 @@ + + +{@render children?.()} diff --git a/frontend/src/lib/features/workflows/__stubs__/SvelteFlowStub.svelte b/frontend/src/lib/features/workflows/__stubs__/SvelteFlowStub.svelte new file mode 100644 index 0000000..97759e4 --- /dev/null +++ b/frontend/src/lib/features/workflows/__stubs__/SvelteFlowStub.svelte @@ -0,0 +1,37 @@ + + +
+ {#each edges as edge (edge.id)} + + {/each} + {#if onconnect} + + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/features/workflows/__stubs__/flowTestState.js b/frontend/src/lib/features/workflows/__stubs__/flowTestState.js new file mode 100644 index 0000000..f3cec03 --- /dev/null +++ b/frontend/src/lib/features/workflows/__stubs__/flowTestState.js @@ -0,0 +1,6 @@ +// Shared state between the SvelteFlow stub and the spec. The stub stands in for +// @xyflow/svelte, which cannot render edges in jsdom; the spec drives the same +// callbacks the library would. +export const flowTestState = { + connectPayload: null, +}; diff --git a/frontend/src/lib/jira-import/JiraImportStore.test.js b/frontend/src/lib/jira-import/JiraImportStore.test.js index dd4fd65..dd6aba8 100644 --- a/frontend/src/lib/jira-import/JiraImportStore.test.js +++ b/frontend/src/lib/jira-import/JiraImportStore.test.js @@ -560,3 +560,29 @@ describe('Jira import interactive mapping', () => { expect(jiraImport.canProceed()).toBe(true); }); }); + +describe('Jira import time limit', () => { + beforeEach(() => { + jiraImport.reset(); + vi.clearAllMocks(); + }); + + it('stops polling and surfaces the time-limit message when the backend reports timed_out', async () => { + jiraImport.import.jobId = 'job-timeout'; + jiraImportApi.getJobStatus.mockResolvedValue({ + status: 'timed_out', + phase: 'timed_out', + progress: { imported_issues: 1800, total_issues: 2535 }, + error_message: + 'Import stopped at the configured time limit after importing 1800 of 2535 issues.', + }); + + jiraImport.pollJobStatus(); + + await vi.waitFor(() => { + expect(jiraImport.import.error).toContain('time limit'); + }); + expect(jiraImport.import.result).toBeNull(); + expect(jiraImportApi.getJobStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/layout/DropdownMenu.test.js b/frontend/src/lib/layout/DropdownMenu.test.js index 4629360..167441d 100644 --- a/frontend/src/lib/layout/DropdownMenu.test.js +++ b/frontend/src/lib/layout/DropdownMenu.test.js @@ -2,6 +2,41 @@ import { render, screen } from '@testing-library/svelte'; import { describe, expect, it } from 'vitest'; import DropdownMenu from './DropdownMenu.svelte'; +describe('DropdownMenu trigger open state', () => { + it('marks the trigger open without overriding an active segment surface', () => { + render(DropdownMenu, { + props: { + isOpen: true, + triggerText: 'Sort: Bubble', + triggerActive: true, + triggerTestid: 'sort-trigger', + items: [{ id: 'rank', title: 'Rank mode', onClick: () => {} }], + }, + }); + + const trigger = screen.getByTestId('sort-trigger'); + expect(trigger).toHaveAttribute('data-open', 'true'); + expect(trigger).toHaveAttribute('data-active', 'true'); + expect(trigger.className).toContain('menu-trigger'); + expect(trigger.getAttribute('style') || '').not.toContain('background-color'); + }); + + it('omits data-active for a trigger that is not an active segment', () => { + render(DropdownMenu, { + props: { + isOpen: true, + triggerText: 'Sort', + triggerTestid: 'sort-trigger', + items: [{ id: 'rank', title: 'Rank mode', onClick: () => {} }], + }, + }); + + const trigger = screen.getByTestId('sort-trigger'); + expect(trigger).toHaveAttribute('data-open', 'true'); + expect(trigger).not.toHaveAttribute('data-active'); + }); +}); + describe('DropdownMenu selected items', () => { it('keeps labels aligned and places the selected checkmark after the label', () => { render(DropdownMenu, { diff --git a/frontend/src/lib/locales/required-locale-coverage.test.js b/frontend/src/lib/locales/required-locale-coverage.test.js index ad75bc5..0c998cb 100644 --- a/frontend/src/lib/locales/required-locale-coverage.test.js +++ b/frontend/src/lib/locales/required-locale-coverage.test.js @@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest'; import de from './de/index.js'; import en from './en/index.js'; import fr from './fr/index.js'; +import pl from './pl/index.js'; import zhCN from './zh-CN/index.js'; -const GUARDED_LOCALES = { de, fr, 'zh-CN': zhCN }; +const GUARDED_LOCALES = { de, fr, pl, 'zh-CN': zhCN }; function collectLeafKeys(object, prefix = '') { const keys = []; @@ -24,6 +25,24 @@ function resolve(object, path) { return path.split('.').reduce((acc, part) => acc?.[part], object); } +const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; + +// Slavic locales carry extra plural forms (`_few`, `_many`) that English does +// not. Mirror check-i18n.js: an extra key is allowed when its base is a plural +// base in English and the locale actually uses that plural category. +function isLocaleSpecificPluralKey(key, englishKeys, localeCode) { + const match = key.match(PLURAL_SUFFIX_PATTERN); + if (!match) return false; + + const baseKey = key.slice(0, -match[0].length); + if (!englishKeys.has(`${baseKey}_other`)) return false; + + const categories = new Set( + new Intl.PluralRules(localeCode).resolvedOptions().pluralCategories + ); + return categories.has(match[1]); +} + // The `{plural}` token is an internal selector, not a user-facing placeholder, // so the guard ignores it when comparing placeholder sets. function placeholdersOf(text) { @@ -46,7 +65,10 @@ describe('guarded locale coverage (WI-1659)', () => { const localeKeys = new Set(collectLeafKeys(messages)); const missing = englishLeafKeys.filter((key) => !localeKeys.has(key)).sort(); - const extra = [...localeKeys].filter((key) => !englishKeys.has(key)).sort(); + const extra = [...localeKeys] + .filter((key) => !englishKeys.has(key)) + .filter((key) => !isLocaleSpecificPluralKey(key, englishKeys, locale)) + .sort(); expect({ missing, extra }).toEqual({ missing: [], extra: [] }); }); diff --git a/frontend/src/lib/mobile/MobileItemEditPage.test.js b/frontend/src/lib/mobile/MobileItemEditPage.test.js new file mode 100644 index 0000000..06df504 --- /dev/null +++ b/frontend/src/lib/mobile/MobileItemEditPage.test.js @@ -0,0 +1,94 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +const routerMocks = vi.hoisted(() => ({ currentRoute: undefined })); +vi.mock('../router.js', async () => { + const { writable } = await import('svelte/store'); + routerMocks.currentRoute = writable({ + path: '/m/items/5/edit', + view: 'mobile-item-edit', + params: { id: '5' }, + query: {}, + }); + return { + navigate: vi.fn(), + setNavigationInterceptor: vi.fn(), + currentRoute: routerMocks.currentRoute, + }; +}); + +const apiMocks = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() })); +vi.mock('../api/items.js', () => ({ + items: { get: apiMocks.get, update: apiMocks.update }, +})); + +vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), + errorToast: vi.fn(), + successToast: vi.fn(), +})); + +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key) => key, + translateError: (err) => err.message, +})); + +import { navigate, setNavigationInterceptor } from '../router.js'; +import MobileItemEditPage from './MobileItemEditPage.svelte'; + +const ITEM = { + id: 5, + workspace_key: 'WI', + workspace_item_number: 5, + title: 'Original', + description: 'Body', +}; + +beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + apiMocks.get.mockResolvedValue({ ...ITEM }); + apiMocks.update.mockResolvedValue({ ...ITEM, title: 'Edited' }); +}); + +afterEach(cleanup); + +test('edits persist as a draft and restore on remount', async () => { + const { unmount } = render(MobileItemEditPage); + const title = await screen.findByTestId('item-edit-title'); + + await fireEvent.input(title, { target: { value: 'Edited' } }); + expect(sessionStorage.getItem('ws-draft:m-edit:5')).toContain('Edited'); + unmount(); + + render(MobileItemEditPage); + expect(await screen.findByTestId('item-edit-title')).toHaveValue('Edited'); +}); + +test('saving clears the draft and returns to the detail page', async () => { + render(MobileItemEditPage); + const title = await screen.findByTestId('item-edit-title'); + + await fireEvent.input(title, { target: { value: 'Edited' } }); + await fireEvent.click(screen.getByTestId('editor-save')); + + await waitFor(() => + expect(apiMocks.update).toHaveBeenCalledWith(5, { + title: 'Edited', + description: 'Body', + }) + ); + expect(sessionStorage.getItem('ws-draft:m-edit:5')).toBeNull(); + expect(navigate).toHaveBeenCalledWith('/m/items/5', { replace: true }); +}); + +test('registers the navigation interceptor and clears it on unmount', async () => { + const { unmount } = render(MobileItemEditPage); + await screen.findByTestId('item-edit-title'); + + expect(setNavigationInterceptor).toHaveBeenCalledTimes(1); + expect(typeof setNavigationInterceptor.mock.calls[0][0]).toBe('function'); + + unmount(); + expect(setNavigationInterceptor).toHaveBeenLastCalledWith(null); +}); diff --git a/frontend/src/lib/mobile/MobileSettingsView.test.js b/frontend/src/lib/mobile/MobileSettingsView.test.js index 806843b..988ea10 100644 --- a/frontend/src/lib/mobile/MobileSettingsView.test.js +++ b/frontend/src/lib/mobile/MobileSettingsView.test.js @@ -20,6 +20,7 @@ vi.mock('../stores/auth.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), infoToast: vi.fn(), successToast: vi.fn(), diff --git a/frontend/src/lib/mobile/mobilePagesData.test.js b/frontend/src/lib/mobile/mobilePagesData.test.js new file mode 100644 index 0000000..0716a7e --- /dev/null +++ b/frontend/src/lib/mobile/mobilePagesData.test.js @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../api/pages.js', () => ({ pages: { getSummaries: vi.fn() } })); + +import { pages } from '../api/pages.js'; +import { BACKGROUND_READ } from '../api/readScheduler.js'; +import { + WORKSPACE_BATCH_SIZE, + fetchWorkspacePageSections, + searchPagesAcrossWorkspaces, +} from './mobilePagesData.js'; + +function summary(workspaceId, overrides = {}) { + return { + workspace_id: workspaceId, + page_id: workspaceId * 10, + parent_id: null, + title: `Page ${workspaceId}`, + depth: 0, + is_home: false, + updated_at: '2026-01-02T03:04:05Z', + ...overrides, + }; +} + +function workspaces(count) { + return Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `WS ${i + 1}` })); +} + +describe('mobilePagesData — bounded cross-workspace page reads', () => { + beforeEach(() => { + pages.getSummaries.mockReset(); + }); + + test('lists every workspace in batches of the server cap', async () => { + const all = workspaces(WORKSPACE_BATCH_SIZE * 2 + 5); + pages.getSummaries.mockImplementation(async (ids) => ids.map((id) => summary(id))); + + const sections = await fetchWorkspacePageSections(all); + + expect(pages.getSummaries).toHaveBeenCalledTimes(3); + expect(pages.getSummaries.mock.calls[0][0]).toHaveLength(WORKSPACE_BATCH_SIZE); + expect(pages.getSummaries.mock.calls[2][0]).toHaveLength(5); + expect(sections).toHaveLength(all.length); + expect(sections[0].pages[0].title).toBe('Page 1'); + }); + + test('opts every batch into the shared read scheduler', async () => { + pages.getSummaries.mockResolvedValue([]); + + await fetchWorkspacePageSections(workspaces(2)); + + expect(pages.getSummaries).toHaveBeenCalledWith([1, 2], { + q: '', + limit: undefined, + signal: undefined, + ...BACKGROUND_READ, + }); + }); + + test('skips a batch that fails without failing the surface', async () => { + const all = workspaces(WORKSPACE_BATCH_SIZE + 2); + pages.getSummaries + .mockRejectedValueOnce(new Error('403')) + .mockImplementationOnce(async (ids) => ids.map((id) => summary(id))); + + const sections = await fetchWorkspacePageSections(all); + + expect(pages.getSummaries).toHaveBeenCalledTimes(2); + expect(sections).toHaveLength(2); + expect(sections[0].workspace.id).toBe(WORKSPACE_BATCH_SIZE + 1); + }); + + test('drops workspaces with no pages from the section list', async () => { + pages.getSummaries.mockImplementation(async (ids) => + ids.filter((id) => id === 2).map((id) => summary(id)) + ); + + const sections = await fetchWorkspacePageSections(workspaces(3)); + + expect(sections).toHaveLength(1); + expect(sections[0].workspace.id).toBe(2); + }); + + test('search tags results with the workspace name and caps the total', async () => { + pages.getSummaries.mockImplementation(async (ids) => ids.map((id) => summary(id))); + + const results = await searchPagesAcrossWorkspaces( + [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, + ], + 'bug', + { cap: 2 } + ); + + expect(pages.getSummaries).toHaveBeenCalledWith([1, 2], { + q: 'bug', + limit: 2, + signal: undefined, + ...BACKGROUND_READ, + }); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ id: 10, workspace_id: 1, workspace_name: 'Alpha' }); + expect(results[1]).toMatchObject({ id: 20, workspace_id: 2, workspace_name: 'Beta' }); + }); + + test('search stops batching once the cap is reached', async () => { + const all = workspaces(WORKSPACE_BATCH_SIZE * 2); + pages.getSummaries.mockImplementation(async (ids) => ids.map((id) => summary(id))); + + const results = await searchPagesAcrossWorkspaces(all, 'bug', { cap: 2 }); + + expect(pages.getSummaries).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(2); + }); + + test('aborting the signal stops further batches', async () => { + const all = workspaces(WORKSPACE_BATCH_SIZE * 2); + const controller = new AbortController(); + pages.getSummaries.mockImplementation(async (ids) => { + controller.abort(); + return ids.map((id) => summary(id)); + }); + + await fetchWorkspacePageSections(all, { signal: controller.signal }); + + expect(pages.getSummaries).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/openui/zodJitless.test.js b/frontend/src/lib/openui/zodJitless.test.js new file mode 100644 index 0000000..7a3f271 --- /dev/null +++ b/frontend/src/lib/openui/zodJitless.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Zod's JIT compiler probes `new Function` when it defines an object schema. +// The app's CSP has no 'unsafe-eval', so the browser reports that probe as a +// securitypolicyviolation even though Zod swallows the thrown error. Loading +// the widget library must not touch the Function constructor at all. +const program = 'root = Card([Title("Open work")])'; + +describe('OpenUI widget parsing under a strict CSP', () => { + it('parses a program without invoking the Function constructor', async () => { + const functionSpy = vi.spyOn(globalThis, 'Function'); + + try { + vi.resetModules(); + const { widgetLibrary } = await import('./library.js'); + const { createParser } = await import('@openuidev/svelte-lang'); + + const parser = createParser(widgetLibrary.toJSONSchema()); + const result = parser.parse(program); + + expect(result.meta?.errors ?? []).toEqual([]); + expect(functionSpy).not.toHaveBeenCalled(); + } finally { + functionSpy.mockRestore(); + } + }); +}); diff --git a/frontend/src/lib/pages/UserProfile.test.js b/frontend/src/lib/pages/UserProfile.test.js index d6a5216..6a4018b 100644 --- a/frontend/src/lib/pages/UserProfile.test.js +++ b/frontend/src/lib/pages/UserProfile.test.js @@ -1,14 +1,14 @@ import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ getUser: vi.fn() })); +const mocks = vi.hoisted(() => ({ getMyProfile: vi.fn() })); const stores = vi.hoisted(() => ({ attachmentStatus: { enabled: true }, authStore: { currentUser: { id: 7 } }, })); vi.mock('../api/users.js', () => ({ - getUser: mocks.getUser, + getMyProfile: mocks.getMyProfile, })); vi.mock('../stores/attachmentStatus.svelte.js', () => ({ @@ -27,21 +27,24 @@ vi.mock('../profile/ProfileAgentsTab.svelte', () => ({ vi.mock('../profile/ProfileCalendarTab.svelte', () => ({ default: function ProfileCalendarTab() {}, })); -vi.mock('../profile/ProfileRegionalSettingsTab.svelte', () => ({ - default: function ProfileRegionalSettingsTab() {}, +vi.mock('../profile/ProfileRegionalSettingsTab.svelte', async () => ({ + default: (await import('../profile/ProfileRegionalSettingsTabStub.svelte')).default, })); import UserProfile from './UserProfile.svelte'; -import { getUser } from '../api/users.js'; +import { getMyProfile } from '../api/users.js'; describe('UserProfile', () => { beforeEach(() => { vi.clearAllMocks(); stores.attachmentStatus.enabled = true; - mocks.getUser.mockResolvedValue({ + mocks.getMyProfile.mockResolvedValue({ id: 7, full_name: 'Ada Lovelace', email: 'ada@example.test', + timezone: 'Europe/Zurich', + language: 'de', + requires_password_reset: true, }); }); @@ -52,8 +55,15 @@ describe('UserProfile', () => { expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); expect(screen.getByText('ada@example.test')).toBeInTheDocument(); - await waitFor(() => expect(mocks.getUser).toHaveBeenCalledOnce()); - expect(mocks.getUser).toHaveBeenCalledWith(7); + expect(screen.getByText('users.passwordResetRequired')).toBeInTheDocument(); + await waitFor(() => expect(mocks.getMyProfile).toHaveBeenCalledOnce()); + }); + + it('passes the loaded private profile fields to the regional settings tab', async () => { + render(UserProfile); + + expect(await screen.findByTestId('regional-settings-language')).toHaveTextContent('de'); + expect(screen.getByTestId('regional-settings-timezone')).toHaveTextContent('Europe/Zurich'); }); it('omits the avatar tab when attachments are unavailable', async () => { diff --git a/frontend/src/lib/pages/mainAppRoutes.test.js b/frontend/src/lib/pages/mainAppRoutes.test.js index 2eee492..c1a921c 100644 --- a/frontend/src/lib/pages/mainAppRoutes.test.js +++ b/frontend/src/lib/pages/mainAppRoutes.test.js @@ -49,8 +49,8 @@ describe('app footer views', () => { // These render an `h-full` navigation pane next to an internally-scrolled // list, so they must not sit in the auto-height footer wrapper. expect(isMainAppFooterView('organizations')).toBe(false); - expect(isMainAppFooterView('logbook')).toBe(false); expect(isMainAppFooterView('configuration-set-detail')).toBe(false); + expect(isMainAppFooterView('api-docs')).toBe(false); }); }); diff --git a/frontend/src/lib/pickers/BasePicker.test.js b/frontend/src/lib/pickers/BasePicker.test.js index d01dbc5..a5d0fb8 100644 --- a/frontend/src/lib/pickers/BasePicker.test.js +++ b/frontend/src/lib/pickers/BasePicker.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { createRawSnippet } from 'svelte'; import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; @@ -405,3 +405,63 @@ describe('BasePicker — grouped options', () => { expect(onSelect).toHaveBeenCalledWith(groupedItems[1]); }); }); + +// Regression: a focused picker with a selected value consumed Escape even when +// its dropdown was closed. Modal.svelte closes only on an unprevented Escape, +// so `preventDefault()` here trapped the user inside a dialog that contains a +// picker (for example the time-log modal). +describe('BasePicker — Escape ownership', () => { + function dispatchEscape(element) { + const event = createEvent.keyDown(element, { key: 'Escape' }); + fireEvent(element, event); + return event.defaultPrevented; + } + + test('lets Escape bubble while the dropdown is closed', async () => { + const onCancel = vi.fn(); + render(BasePicker, { props: { items, value: 1, onCancel } }); + + const input = screen.getByRole('combobox'); + input.focus(); + + expect(dispatchEscape(input)).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.queryByTestId('picker-dropdown')).toBeNull(); + }); + + test('consumes Escape and closes the dropdown while open', async () => { + const onCancel = vi.fn(); + render(BasePicker, { props: { items, onCancel } }); + + const input = await openAndType('Ap'); + await waitFor(() => { + expect(document.querySelector('[data-option-value="1"]')).toBeInTheDocument(); + }); + + expect(dispatchEscape(input)).toBe(true); + expect(onCancel).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByTestId('picker-dropdown')).toBeNull()); + }); + + // The closed-state guard must not swallow the keys that open the dropdown: + // a focused combobox input is "closed" until Melt's own handler opens it. + test('still opens the dropdown on ArrowDown while closed', async () => { + render(BasePicker, { props: { items } }); + + const input = screen.getByRole('combobox'); + input.focus(); + + await fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(await screen.findByTestId('picker-dropdown')).toBeInTheDocument(); + }); + + test('still opens the dropdown on typing while closed', async () => { + render(BasePicker, { props: { items } }); + + const input = screen.getByRole('combobox'); + input.focus(); + + await fireEvent.keyDown(input, { key: 'a' }); + expect(await screen.findByTestId('picker-dropdown')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/pickers/CalendarGrid.test.js b/frontend/src/lib/pickers/CalendarGrid.test.js new file mode 100644 index 0000000..aa8c521 --- /dev/null +++ b/frontend/src/lib/pickers/CalendarGrid.test.js @@ -0,0 +1,96 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ + i18n: { locale: 'en', isRTL: false }, + t: (key) => key +})); + +beforeAll(() => { + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import CalendarGrid from './CalendarGrid.svelte'; + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +describe('CalendarGrid', () => { + it('renders the Persian calendar for the fa locale', () => { + render(CalendarGrid, { + props: { + calendarId: 'persian', + locale: 'fa', + value: '2026-11-07', + dataTestid: 'cal' + } + }); + + expect(screen.getByText('ÛąÛ´Û°Ûĩ ØĸØ¨Ø§Ų†')).toBeInTheDocument(); + const selected = screen.getByTestId('cal-day-2026-11-07'); + expect(selected).toHaveAttribute('data-selected'); + expect(selected).toHaveTextContent('ÛąÛļ'); + }); + + it('renders the Gregorian calendar for en-US', () => { + render(CalendarGrid, { + props: { + calendarId: 'gregory', + locale: 'en-US', + value: '2026-11-07', + dataTestid: 'cal' + } + }); + + expect(screen.getByText('November 2026')).toBeInTheDocument(); + const selected = screen.getByTestId('cal-day-2026-11-07'); + expect(selected).toHaveAttribute('data-selected'); + expect(selected).toHaveTextContent('7'); + }); + + it('starts the week on Saturday for fa', () => { + render(CalendarGrid, { + props: { calendarId: 'persian', locale: 'fa', value: '2026-11-07', dataTestid: 'cal' } + }); + + const headers = document.querySelectorAll('[data-testid="cal"] thead th'); + expect(headers).toHaveLength(7); + expect(headers[0]).toHaveTextContent('Ø´Ų†Ø¨Ų‡'); + }); + + it('starts the week on Sunday for en-US', () => { + render(CalendarGrid, { + props: { calendarId: 'gregory', locale: 'en-US', value: '2026-11-07', dataTestid: 'cal' } + }); + + const headers = document.querySelectorAll('[data-testid="cal"] thead th'); + expect(headers).toHaveLength(7); + expect(headers[0]).toHaveTextContent('Sun'); + }); + + it('emits the Gregorian date when a Persian day is selected', async () => { + const onselect = vi.fn(); + render(CalendarGrid, { + props: { + calendarId: 'persian', + locale: 'fa', + value: '2026-11-07', + onselect, + dataTestid: 'cal' + } + }); + + await fireEvent.click(screen.getByTestId('cal-day-2026-11-20')); + + expect(onselect).toHaveBeenCalledTimes(1); + expect(onselect).toHaveBeenCalledWith('2026-11-20'); + }); +}); diff --git a/frontend/src/lib/pickers/ChipPicker.test.js b/frontend/src/lib/pickers/ChipPicker.test.js new file mode 100644 index 0000000..a783fcf --- /dev/null +++ b/frontend/src/lib/pickers/ChipPicker.test.js @@ -0,0 +1,74 @@ +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; + +beforeAll(() => { + // jsdom lacks scrollIntoView, which the picker uses to keep the highlighted + // row visible. + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } + // Melt's floating popover observes the reference element. + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import ChipPicker from './ChipPicker.svelte'; + +afterEach(() => { + // The popover is portalled to document.body; wipe it so each test starts + // clean. + document.body.innerHTML = ''; +}); + +const items = [ + { id: 1, name: 'Apple' }, + { id: 2, name: 'Banana' }, +]; + +// Regression: a focused chip picker consumed Escape (preventDefault + +// stopPropagation) even when its popover was closed, trapping the user inside +// any dialog that contains one (for example the create modal). +describe('ChipPicker — Escape ownership', () => { + test('lets Escape bubble while the popover is closed', async () => { + render(ChipPicker, { props: { items, value: 1, testId: 'chip' } }); + const trigger = screen.getByTestId('chip'); + + const bubble = vi.fn(); + document.addEventListener('keydown', bubble); + try { + const event = createEvent.keyDown(trigger, { key: 'Escape' }); + fireEvent(trigger, event); + + expect(event.defaultPrevented).toBe(false); + expect(bubble).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('chip-dropdown')).toBeNull(); + } finally { + document.removeEventListener('keydown', bubble); + } + }); + + test('consumes Escape and closes the popover while open', async () => { + render(ChipPicker, { props: { items, testId: 'chip' } }); + const trigger = screen.getByTestId('chip'); + await fireEvent.click(trigger); + await screen.findByTestId('chip-listbox'); + + const bubble = vi.fn(); + document.addEventListener('keydown', bubble); + try { + const event = createEvent.keyDown(trigger, { key: 'Escape' }); + fireEvent(trigger, event); + + expect(event.defaultPrevented).toBe(true); + expect(bubble).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByTestId('chip-dropdown')).toBeNull()); + } finally { + document.removeEventListener('keydown', bubble); + } + }); +}); diff --git a/frontend/src/lib/pickers/DatePicker.test.js b/frontend/src/lib/pickers/DatePicker.test.js new file mode 100644 index 0000000..f6d6c1a --- /dev/null +++ b/frontend/src/lib/pickers/DatePicker.test.js @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +beforeAll(() => { + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +import DatePicker from './DatePicker.svelte'; + +// DatePicker lazy-loads the non-Gregorian field; warm the module cache so the +// Persian test does not race the dynamic import under full-suite load. +beforeAll(async () => { + await import('./CalendarDateField.svelte'); +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +describe('DatePicker', () => { + it('renders a native date input for Gregorian locales', async () => { + const onchange = vi.fn(); + render(DatePicker, { + props: { value: '2026-11-07', locale: 'en', dataTestid: 'dp', onchange } + }); + + const input = screen.getByTestId('dp'); + expect(input).toHaveAttribute('type', 'date'); + expect(input).toHaveValue('2026-11-07'); + + await fireEvent.change(input, { target: { value: '2026-12-01' } }); + expect(onchange).toHaveBeenCalledWith('2026-12-01', expect.anything()); + }); + + it('renders a Jalali trigger and calendar for Persian locales', async () => { + const onchange = vi.fn(); + render(DatePicker, { + props: { value: '2026-11-07', locale: 'fa', dataTestid: 'dp', onchange } + }); + + const trigger = await screen.findByTestId('dp'); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveTextContent('ÛąÛļ ØĸØ¨Ø§Ų† ÛąÛ´Û°Ûĩ'); + + await fireEvent.click(trigger); + + const day = await screen.findByTestId('dp-calendar-day-2026-11-20'); + await fireEvent.click(day); + + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenCalledWith('2026-11-20'); + }); +}); diff --git a/frontend/src/lib/pickers/LinkingFieldPicker.test.js b/frontend/src/lib/pickers/LinkingFieldPicker.test.js index b82815b..2f3240b 100644 --- a/frontend/src/lib/pickers/LinkingFieldPicker.test.js +++ b/frontend/src/lib/pickers/LinkingFieldPicker.test.js @@ -11,6 +11,7 @@ vi.mock('../api/configuration.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/pickers/MentionPicker.test.js b/frontend/src/lib/pickers/MentionPicker.test.js index fdd47f3..a41515b 100644 --- a/frontend/src/lib/pickers/MentionPicker.test.js +++ b/frontend/src/lib/pickers/MentionPicker.test.js @@ -10,12 +10,12 @@ beforeAll(() => { // MentionPicker fetches users when opened. Provide a fixed roster. vi.mock('../api/users.js', () => ({ getUsers: vi.fn(async () => [ - { username: "alice", first_name: "Alice", last_name: "Johnson" }, - { username: "bob", first_name: "Bob", last_name: "Smith" }, + { id: 1, username: "alice", first_name: "Alice", last_name: "Johnson" }, + { id: 2, username: "bob", first_name: "Bob", last_name: "Smith" }, ]), getAssignableUsers: vi.fn(async () => [ - { username: "alice", first_name: "Alice", last_name: "Johnson" }, - { username: "bob", first_name: "Bob", last_name: "Smith" }, + { id: 1, username: "alice", first_name: "Alice", last_name: "Johnson" }, + { id: 2, username: "bob", first_name: "Bob", last_name: "Smith" }, ]), })); @@ -104,10 +104,10 @@ describe("MentionPicker — shared workspace roster", () => { }); describe("MentionPicker — mention identity", () => { - test("inserts a username when another user shares the display name", async () => { + test("embeds the user id even when another user shares the display name", async () => { getUsers.mockResolvedValueOnce([ - { username: "jdoe", first_name: "John", last_name: "Doe" }, - { username: "john.doe", first_name: "John", last_name: "Doe" }, + { id: 1, username: "jdoe", first_name: "John", last_name: "Doe" }, + { id: 2, username: "john.doe", first_name: "John", last_name: "Doe" }, ]); const onSelect = vi.fn(); render(MentionPicker, { props: { open: true, onSelect } }); @@ -119,13 +119,13 @@ describe("MentionPicker — mention identity", () => { pressKey("Enter"); expect(onSelect).toHaveBeenCalledWith( - expect.objectContaining({ username: "jdoe", mentionText: "@jdoe" }), + expect.objectContaining({ username: "jdoe", mentionText: "@{John Doe#1}" }), ); }); - test("keeps a unique display name as a quoted mention", async () => { + test("embeds the user id for a unique display name", async () => { getUsers.mockResolvedValueOnce([ - { username: "jsmith", first_name: "Jane", last_name: "Smith" }, + { id: 7, username: "jsmith", first_name: "Jane", last_name: "Smith" }, ]); const onSelect = vi.fn(); render(MentionPicker, { props: { open: true, onSelect } }); @@ -137,7 +137,7 @@ describe("MentionPicker — mention identity", () => { pressKey("Enter"); expect(onSelect).toHaveBeenCalledWith( - expect.objectContaining({ username: "jsmith", mentionText: '@"Jane Smith"' }), + expect.objectContaining({ username: "jsmith", mentionText: "@{Jane Smith#7}" }), ); }); }); diff --git a/frontend/src/lib/pickers/MilestoneCombobox.test.js b/frontend/src/lib/pickers/MilestoneCombobox.test.js index 0e1eaa4..90fd52d 100644 --- a/frontend/src/lib/pickers/MilestoneCombobox.test.js +++ b/frontend/src/lib/pickers/MilestoneCombobox.test.js @@ -62,6 +62,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/pickers/UserPicker.test.js b/frontend/src/lib/pickers/UserPicker.test.js index 4651b66..fe10be4 100644 --- a/frontend/src/lib/pickers/UserPicker.test.js +++ b/frontend/src/lib/pickers/UserPicker.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/svelte'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../api/users.js', () => ({ @@ -83,3 +83,37 @@ describe('UserPicker disambiguates duplicate display names', () => { expect(trigger).not.toHaveTextContent('@asmith'); }); }); + +// The create form applies its workspace asynchronously and the user may switch +// it while the picker is mounted. The roster must always match the current +// workspace instead of the unscoped fallback (GH #420). +describe('UserPicker workspace scope', () => { + it('reloads the roster when the workspace changes', async () => { + getAssignableUsers.mockImplementation(async (workspaceId) => + workspaceId === 5 + ? [{ id: 1, username: 'five', first_name: 'Five', last_name: 'User' }] + : [{ id: 2, username: 'six', first_name: 'Six', last_name: 'User' }] + ); + + const { rerender } = render(UserPicker, { props: { value: null, workspaceId: 5 } }); + await waitFor(() => expect(getAssignableUsers).toHaveBeenCalledWith(5)); + + await rerender({ value: null, workspaceId: 6 }); + await waitFor(() => expect(getAssignableUsers).toHaveBeenCalledWith(6)); + + await fireEvent.click(screen.getByTestId('user-picker-trigger')); + expect(await screen.findByTestId('user-picker-option-2')).toBeInTheDocument(); + expect(screen.queryByTestId('user-picker-option-1')).not.toBeInTheDocument(); + }); + + it('does not refetch parent-supplied options when the workspace changes', async () => { + const { rerender } = render(UserPicker, { + props: { users: [], value: null, workspaceId: 5 }, + }); + + await rerender({ users: [], value: null, workspaceId: 6 }); + await Promise.resolve(); + + expect(getAssignableUsers).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js b/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js index b5c10e2..6b412da 100644 --- a/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js +++ b/frontend/src/lib/pickers/WorkspaceLabelCombobox.test.js @@ -17,6 +17,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/profile/ProfileRegionalSettingsTabStub.svelte b/frontend/src/lib/profile/ProfileRegionalSettingsTabStub.svelte new file mode 100644 index 0000000..0bbeba5 --- /dev/null +++ b/frontend/src/lib/profile/ProfileRegionalSettingsTabStub.svelte @@ -0,0 +1,8 @@ + + +
+ {user?.language} + {user?.timezone} +
diff --git a/frontend/src/lib/profile/ProfileTabs.test.js b/frontend/src/lib/profile/ProfileTabs.test.js index 553faf6..0143742 100644 --- a/frontend/src/lib/profile/ProfileTabs.test.js +++ b/frontend/src/lib/profile/ProfileTabs.test.js @@ -39,7 +39,7 @@ vi.mock('../api/users.js', () => ({ })); vi.mock('../stores/auth.svelte.js', () => ({ - authStore: { patchCurrentUser: mocks.patchCurrentUser }, + authStore: { currentUser: { id: 7 }, patchCurrentUser: mocks.patchCurrentUser }, })); vi.mock('../stores/i18n.svelte.js', () => ({ diff --git a/frontend/src/lib/runtime/serverInfo.test.js b/frontend/src/lib/runtime/serverInfo.test.js new file mode 100644 index 0000000..3c6e254 --- /dev/null +++ b/frontend/src/lib/runtime/serverInfo.test.js @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { isServerBaseURLConfigured, serverBaseURL } from './serverInfo.js'; + +describe('serverBaseURL', () => { + afterEach(() => { + delete window.__WINDSHIFT_BASE_URL__; + delete window.__WINDSHIFT_BASE_URL_CONFIGURED__; + }); + + it('returns null when the server did not inject a base URL', () => { + expect(serverBaseURL()).toBeNull(); + expect(isServerBaseURLConfigured()).toBeNull(); + }); + + it('returns the injected base URL without a trailing slash', () => { + window.__WINDSHIFT_BASE_URL__ = 'https://windshift.example.test/'; + + expect(serverBaseURL()).toBe('https://windshift.example.test'); + }); + + it('reports whether the base URL was explicitly configured', () => { + window.__WINDSHIFT_BASE_URL__ = 'http://localhost:8080'; + window.__WINDSHIFT_BASE_URL_CONFIGURED__ = false; + + expect(serverBaseURL()).toBe('http://localhost:8080'); + expect(isServerBaseURLConfigured()).toBe(false); + + window.__WINDSHIFT_BASE_URL_CONFIGURED__ = true; + expect(isServerBaseURLConfigured()).toBe(true); + }); +}); diff --git a/frontend/src/lib/services/PluginBridge.test.js b/frontend/src/lib/services/PluginBridge.test.js new file mode 100644 index 0000000..6d2c1b9 --- /dev/null +++ b/frontend/src/lib/services/PluginBridge.test.js @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPluginBridge, MESSAGE_TYPES } from './PluginBridge.js'; + +function makeIframe() { + return { contentWindow: { postMessage: vi.fn() } }; +} + +function dispatch(iframe, message, { origin = window.location.origin, source } = {}) { + window.dispatchEvent( + new MessageEvent('message', { + data: message, + origin, + source: source === undefined ? iframe.contentWindow : source, + }) + ); +} + +describe('createPluginBridge editor protocol', () => { + let bridge = null; + + afterEach(() => { + bridge?.destroy(); + bridge = null; + }); + + it('routes an editor message that echoes the session id', () => { + const iframe = makeIframe(); + const onEditorSave = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorSave }); + + dispatch(iframe, { type: MESSAGE_TYPES.EDITOR_SAVE, sessionId: 's1', markdown: '# hi' }); + + expect(onEditorSave).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 's1', markdown: '# hi' }) + ); + }); + + it('drops an editor message with a mismatched session id', () => { + const iframe = makeIframe(); + const onEditorSave = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorSave }); + + dispatch(iframe, { type: MESSAGE_TYPES.EDITOR_SAVE, sessionId: 'other', markdown: '# hi' }); + + expect(onEditorSave).not.toHaveBeenCalled(); + }); + + it('accepts the editor ready handshake before the session id is known', () => { + // Regression: the plugin only learns the session id from host:editorInit, + // which the host sends in response to this handshake. Gating ready on the + // session id deadlocked the editor so it never initialized. + const iframe = makeIframe(); + const onEditorReady = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorReady }); + + dispatch(iframe, { type: MESSAGE_TYPES.EDITOR_READY }); + + expect(onEditorReady).toHaveBeenCalledTimes(1); + }); + + it('still drops an editor change that omits the session id', () => { + const iframe = makeIframe(); + const onEditorChange = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorChange }); + + dispatch(iframe, { type: MESSAGE_TYPES.EDITOR_CHANGE, markdown: '# hi', dirty: true }); + + expect(onEditorChange).not.toHaveBeenCalled(); + }); + + it('drops a message from a different window', () => { + const iframe = makeIframe(); + const onEditorSave = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorSave }); + + dispatch( + iframe, + { type: MESSAGE_TYPES.EDITOR_SAVE, sessionId: 's1', markdown: '# hi' }, + { source: {} } + ); + + expect(onEditorSave).not.toHaveBeenCalled(); + }); + + it('drops a message from a different origin', () => { + const iframe = makeIframe(); + const onEditorSave = vi.fn(); + bridge = createPluginBridge(iframe, { sessionId: 's1', onEditorSave }); + + dispatch( + iframe, + { type: MESSAGE_TYPES.EDITOR_SAVE, sessionId: 's1', markdown: '# hi' }, + { origin: 'https://evil.example' } + ); + + expect(onEditorSave).not.toHaveBeenCalled(); + }); + + it('sends editor init with the session id', () => { + const iframe = makeIframe(); + bridge = createPluginBridge(iframe, { sessionId: 's1' }); + + bridge.sendEditorInit({ markdown: '# hi', entityType: 'item' }); + + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: MESSAGE_TYPES.EDITOR_INIT, + sessionId: 's1', + markdown: '# hi', + entityType: 'item', + }), + window.location.origin + ); + }); + + it('still routes the legacy ready and resize messages', () => { + const iframe = makeIframe(); + const onReady = vi.fn(); + const onResize = vi.fn(); + bridge = createPluginBridge(iframe, { onReady, onResize }); + + dispatch(iframe, { type: MESSAGE_TYPES.READY }); + dispatch(iframe, { type: MESSAGE_TYPES.RESIZE, height: 321 }); + + expect(onReady).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith(321); + }); +}); diff --git a/frontend/src/lib/services/appShellHandlers.spec.js b/frontend/src/lib/services/appShellHandlers.spec.js new file mode 100644 index 0000000..1ff9478 --- /dev/null +++ b/frontend/src/lib/services/appShellHandlers.spec.js @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../api/core.js', () => ({ configureAPIEventHandlers: vi.fn() })); +vi.mock('../mobile/serviceWorkerClient.js', () => ({ + configureServiceWorkerNotifications: vi.fn(), +})); +vi.mock('../router.js', () => ({ navigate: vi.fn() })); +vi.mock('../stores/auth.svelte.js', () => ({ + authStore: { subscribe: vi.fn(), clearAuth: vi.fn() }, +})); +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key) => `t:${key}`, + translateError: (error) => `translated:${error?.message ?? error}`, +})); +vi.mock('../utils/crossTabSync.js', () => ({ initCrossTabSync: vi.fn() })); + +import { installGlobalErrorHandlers } from './appShellHandlers.js'; +import { removeToast, toasts } from '../stores/toasts.svelte.js'; + +afterEach(() => { + for (const toast of [...toasts.value]) removeToast(toast.id); + vi.restoreAllMocks(); +}); + +function rejectionEvent(reason) { + const event = new Event('unhandledrejection'); + event.reason = reason; + return event; +} + +function errorEvent(error) { + const event = new Event('error'); + event.error = error; + return event; +} + +describe('installGlobalErrorHandlers', () => { + it('surfaces an unhandled rejection as an error toast', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const target = new EventTarget(); + installGlobalErrorHandlers(target); + + target.dispatchEvent(rejectionEvent(new Error('boom'))); + + expect(toasts.value).toHaveLength(1); + expect(toasts.value[0]).toMatchObject({ variant: 'error', message: 'translated:boom' }); + }); + + it('ignores aborted rejections', () => { + const target = new EventTarget(); + installGlobalErrorHandlers(target); + + const abort = new Error('aborted'); + abort.name = 'AbortError'; + target.dispatchEvent(rejectionEvent(abort)); + + expect(toasts.value).toHaveLength(0); + }); + + it('ignores the runed debounce cancel sentinel leaked by onClickOutside', () => { + // runed's onClickOutside schedules an internal useDebounce and discards its + // promise; cancelling it on cleanup rejects with the bare string sentinel. + const target = new EventTarget(); + installGlobalErrorHandlers(target); + + target.dispatchEvent(rejectionEvent('Cancelled')); + + expect(toasts.value).toHaveLength(0); + }); + + it('surfaces a window error event', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const target = new EventTarget(); + installGlobalErrorHandlers(target); + + target.dispatchEvent(errorEvent(new Error('sync failure'))); + + expect(toasts.value).toHaveLength(1); + expect(toasts.value[0]).toMatchObject({ message: 'translated:sync failure' }); + }); + + it('ignores resource load errors that carry no error object', () => { + const target = new EventTarget(); + installGlobalErrorHandlers(target); + + target.dispatchEvent(errorEvent(undefined)); + + expect(toasts.value).toHaveLength(0); + }); + + it('installs only once per target', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const target = new EventTarget(); + installGlobalErrorHandlers(target); + installGlobalErrorHandlers(target); + + target.dispatchEvent(rejectionEvent(new Error('boom'))); + + expect(toasts.value).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/services/appShellHandlers.test.js b/frontend/src/lib/services/appShellHandlers.test.js index ff197dc..a29cba4 100644 --- a/frontend/src/lib/services/appShellHandlers.test.js +++ b/frontend/src/lib/services/appShellHandlers.test.js @@ -35,6 +35,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), addToast: vi.fn(), })); diff --git a/frontend/src/lib/services/authenticatedShellUI.test.js b/frontend/src/lib/services/authenticatedShellUI.test.js index ccfab8b..69497c5 100644 --- a/frontend/src/lib/services/authenticatedShellUI.test.js +++ b/frontend/src/lib/services/authenticatedShellUI.test.js @@ -14,9 +14,6 @@ vi.mock('../stores/aiStore.svelte.js', () => ({ vi.mock('../stores/capabilities.svelte.js', () => ({ capabilitiesStore: { hydrate: vi.fn(), failHydration: vi.fn() }, })); -vi.mock('../stores/logbook.svelte.js', () => ({ - logbookStore: { hydrateAvailability: vi.fn() }, -})); vi.mock('../stores/workItemStalenessSettings.svelte.js', () => ({ workItemStalenessSettings: { hydrate: vi.fn() }, })); @@ -25,7 +22,6 @@ vi.mock('../stores/moduleSettings.js', () => ({ })); vi.mock('../stores/permissions.svelte.js', () => ({ permissionStore: { - setLogbookAvailable: vi.fn(), setHasAssetSets: vi.fn(), setHasActivePortals: vi.fn(), setHasPortals: vi.fn(), @@ -48,7 +44,6 @@ import { aiStore } from '../stores/aiStore.svelte.js'; import { attachmentStatus } from '../stores/attachmentStatus.svelte.js'; import { capabilitiesStore } from '../stores/capabilities.svelte.js'; import { workItemStalenessSettings } from '../stores/workItemStalenessSettings.svelte.js'; -import { logbookStore } from '../stores/logbook.svelte.js'; import { moduleSettings } from '../stores/moduleSettings.js'; import { permissionStore } from '../stores/permissions.svelte.js'; import { themeStore } from '../stores/theme.svelte.js'; @@ -64,7 +59,6 @@ const snapshot = { ai: { available: true, chat_enabled: true }, features: { capabilities: ['plugin.example'], - logbook_available: true, }, has_asset_sets: true, manages_asset_sets: true, @@ -90,9 +84,7 @@ describe('authenticated shell UI refresh', () => { expect(attachmentStatus.hydrate).toHaveBeenCalledWith(snapshot.attachment_status); expect(aiStore.hydrate).toHaveBeenCalledWith(snapshot.ai); expect(capabilitiesStore.hydrate).toHaveBeenCalledWith(snapshot.features); - expect(logbookStore.hydrateAvailability).toHaveBeenCalledWith(true); expect(workItemStalenessSettings.hydrate).toHaveBeenCalledWith(snapshot.work_item_staleness); - expect(permissionStore.setLogbookAvailable).toHaveBeenCalledWith(true); expect(permissionStore.setHasAssetSets).toHaveBeenCalledWith(true); expect(permissionStore.setHasPortals).toHaveBeenCalledWith(true); expect(permissionStore.setHasActivePortals).toHaveBeenCalledWith(false); diff --git a/frontend/src/lib/settings/AttachmentSettings.test.js b/frontend/src/lib/settings/AttachmentSettings.test.js index 2c6af88..f82f637 100644 --- a/frontend/src/lib/settings/AttachmentSettings.test.js +++ b/frontend/src/lib/settings/AttachmentSettings.test.js @@ -14,6 +14,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/ConfigurationSetDetail.test.js b/frontend/src/lib/settings/ConfigurationSetDetail.test.js index fb122f6..3c2a1d6 100644 --- a/frontend/src/lib/settings/ConfigurationSetDetail.test.js +++ b/frontend/src/lib/settings/ConfigurationSetDetail.test.js @@ -63,7 +63,7 @@ vi.mock('../api/workspaces.js', () => { }); vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); -vi.mock('../stores/toasts.svelte.js', () => ({ errorToast: vi.fn() })); +vi.mock('../stores/toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: vi.fn() })); vi.mock('./LocalizedObjectFields.svelte', () => ({ default: function LocalizedObjectFields() { return { save: vi.fn(), validate: vi.fn() }; diff --git a/frontend/src/lib/settings/ConfigurationSetManager.test.js b/frontend/src/lib/settings/ConfigurationSetManager.test.js new file mode 100644 index 0000000..501fafe --- /dev/null +++ b/frontend/src/lib/settings/ConfigurationSetManager.test.js @@ -0,0 +1,154 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + errorToast: vi.fn(), + successToast: vi.fn(), + get: vi.fn(), + importTemplate: vi.fn(), + navigate: vi.fn(), +})); + +vi.mock('../api/core.js', () => ({ get: mocks.get })); + +vi.mock('../api/configuration.js', () => ({ + configurationSets: { + import: mocks.importTemplate, + exportUrl: (id) => `/api/configuration-sets/${id}/export`, + delete: vi.fn(), + }, +})); + +vi.mock('../api/notifications.js', () => ({ + notificationSettings: { getAll: vi.fn(() => Promise.resolve([])) }, +})); + +vi.mock('../api/workflows.js', () => ({ + workflows: { getAll: vi.fn(() => Promise.resolve([])) }, +})); + +vi.mock('../api/workspaces.js', () => ({ + workspaces: { getPage: vi.fn(() => Promise.resolve({ data: [], pagination: { total: 0 } })) }, +})); + +vi.mock('../router.js', () => ({ navigate: mocks.navigate })); + +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (key, params = {}) => + Object.keys(params).length > 0 + ? `${key}(${Object.values(params).join(',')})` + : key, +})); + +vi.mock('../stores/toasts.svelte.js', () => ({ + errorToast: mocks.errorToast, + successToast: mocks.successToast, +})); + +vi.mock('../composables/useConfirm.js', () => ({ confirm: vi.fn(() => Promise.resolve(true)) })); + +import ConfigurationSetManager from './ConfigurationSetManager.svelte'; + +const TEMPLATE = { + schema_version: 1, + kind: 'windshift.configuration-set', + payload: { + configuration_set: { name: 'Support Desk' }, + workflows: [{ name: 'Support Desk Workflow', transitions: [] }], + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.get.mockImplementation((url) => { + if (url.startsWith('/configuration-sets')) { + return Promise.resolve({ + configuration_sets: [], + pagination: { page: 1, limit: 10, total: 0 }, + }); + } + return Promise.resolve([]); + }); + mocks.importTemplate.mockImplementation((template) => + Promise.resolve({ + data: { id: 9, name: template.payload.configuration_set.name }, + warnings: [], + }) + ); +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +function importFileInput() { + return document.querySelector('[data-testid="configuration-set-import-file"]'); +} + +async function pickTemplate(template = TEMPLATE) { + // jsdom's File does not implement Blob.text(), which the component uses to + // read the upload; a text() shim exercises the same production path. + const file = { text: () => Promise.resolve(JSON.stringify(template)) }; + await fireEvent.change(importFileInput(), { target: { files: [file] } }); +} + +describe('ConfigurationSetManager import-as-clone', () => { + it('prompts for a name prefilled from the bundle before importing', async () => { + render(ConfigurationSetManager); + await waitFor(() => expect(mocks.get).toHaveBeenCalled()); + + await pickTemplate(); + + const nameInput = await screen.findByTestId('configuration-set-import-name'); + expect(nameInput).toHaveValue('Support Desk Copy'); + // Nothing is written until the operator confirms the name. + expect(mocks.importTemplate).not.toHaveBeenCalled(); + }); + + it('sends the template with the prompted name and preserves the rest', async () => { + render(ConfigurationSetManager); + await waitFor(() => expect(mocks.get).toHaveBeenCalled()); + + await pickTemplate(); + const nameInput = await screen.findByTestId('configuration-set-import-name'); + await fireEvent.input(nameInput, { target: { value: 'Support Desk EU' } }); + await fireEvent.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(mocks.importTemplate).toHaveBeenCalledTimes(1)); + expect(mocks.importTemplate).toHaveBeenCalledWith({ + ...TEMPLATE, + payload: { + ...TEMPLATE.payload, + configuration_set: { name: 'Support Desk EU' }, + }, + }); + expect(mocks.successToast).toHaveBeenCalledWith('settings.configSets.importSuccess(Support Desk EU)'); + }); + + it('requires a name before importing', async () => { + render(ConfigurationSetManager); + await waitFor(() => expect(mocks.get).toHaveBeenCalled()); + + await pickTemplate(); + const nameInput = await screen.findByTestId('configuration-set-import-name'); + await fireEvent.input(nameInput, { target: { value: ' ' } }); + await fireEvent.click(screen.getByTestId('dialog-confirm')); + + expect(await screen.findByText(/^validation\.requiredField/)).toBeInTheDocument(); + expect(mocks.importTemplate).not.toHaveBeenCalled(); + }); + + it('rejects a file that is not a configuration set template', async () => { + render(ConfigurationSetManager); + await waitFor(() => expect(mocks.get).toHaveBeenCalled()); + + const file = { text: () => Promise.resolve('not json') }; + await fireEvent.change(importFileInput(), { target: { files: [file] } }); + + await waitFor(() => + expect(mocks.errorToast).toHaveBeenCalledWith('settings.configSets.importInvalidFile') + ); + expect(screen.queryByTestId('configuration-set-import-name')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/settings/EntityManagerPage.test.js b/frontend/src/lib/settings/EntityManagerPage.test.js new file mode 100644 index 0000000..3429faf --- /dev/null +++ b/frontend/src/lib/settings/EntityManagerPage.test.js @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, expect, test, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); +vi.mock('../composables/useConfirm.js', () => ({ confirm: vi.fn(async () => true) })); +vi.mock('../utils/keyboardShortcuts.js', () => ({ + toHotkeyString: () => 'A', + getShortcut: () => null, + matchesShortcut: () => false, + getDisplayString: () => '', +})); + +import { confirm } from '../composables/useConfirm.js'; +import EntityManagerPageHarness from './EntityManagerPageHarness.svelte'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test('loads rows and creates through the modal', async () => { + const load = vi.fn(async () => [{ id: 1, name: 'Alpha' }]); + const create = vi.fn(async () => ({})); + + render(EntityManagerPageHarness, { + props: { load, create, update: vi.fn(), remove: vi.fn() }, + }); + + await screen.findByTestId('widget-row-1'); + + await fireEvent.click(screen.getByTestId('widget-add')); + await fireEvent.input(screen.getByTestId('widget-name'), { target: { value: 'Beta' } }); + await fireEvent.click(screen.getByTestId('entity-form-confirm')); + + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + // Initial load plus the reload after the create. + expect(load).toHaveBeenCalledTimes(2); +}); + +test('edits a row through the modal', async () => { + const load = vi.fn(async () => [{ id: 2, name: 'Alpha' }]); + const update = vi.fn(async () => ({})); + + render(EntityManagerPageHarness, { + props: { load, create: vi.fn(), update, remove: vi.fn() }, + }); + + await fireEvent.click(await screen.findByTestId('widget-edit-2')); + await fireEvent.input(screen.getByTestId('widget-name'), { target: { value: 'Gamma' } }); + await fireEvent.click(screen.getByTestId('entity-form-confirm')); + + await waitFor(() => expect(update).toHaveBeenCalledWith(2)); +}); + +test('confirms before deleting a row', async () => { + const load = vi.fn(async () => [{ id: 5, name: 'Alpha' }]); + const remove = vi.fn(async () => ({})); + + render(EntityManagerPageHarness, { + props: { load, create: vi.fn(), update: vi.fn(), remove }, + }); + + await fireEvent.click(await screen.findByTestId('widget-delete-5')); + + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(remove).toHaveBeenCalledTimes(1)); +}); + +test('renders the load error when loading fails', async () => { + const load = vi.fn(async () => { + throw new Error('nope'); + }); + + render(EntityManagerPageHarness, { + props: { load, create: vi.fn(), update: vi.fn(), remove: vi.fn() }, + }); + + // The scaffold prefers the thrown message over the generic label. + expect(await screen.findByText('nope')).toBeInTheDocument(); +}); diff --git a/frontend/src/lib/settings/EntityManagerPageHarness.svelte b/frontend/src/lib/settings/EntityManagerPageHarness.svelte new file mode 100644 index 0000000..ea7d66a --- /dev/null +++ b/frontend/src/lib/settings/EntityManagerPageHarness.svelte @@ -0,0 +1,42 @@ + + + { + form = { name: '' }; + }} + onOpenEdit={(item) => { + form = { name: item.name }; + }} + confirmDeleteMessage={(item) => `Delete ${item.name}?`} + rowTestid={(item) => `widget-row-${item.id}`} + actionTestidPrefix="widget" + submitDisabled={() => !form.name.trim()} + formFields={fields} +> + + +{#snippet fields()} + +{/snippet} diff --git a/frontend/src/lib/settings/ItemTypeManager.test.js b/frontend/src/lib/settings/ItemTypeManager.test.js index 9dcba1e..8725a14 100644 --- a/frontend/src/lib/settings/ItemTypeManager.test.js +++ b/frontend/src/lib/settings/ItemTypeManager.test.js @@ -39,6 +39,7 @@ vi.mock("../stores/i18n.svelte.js", () => ({ })); vi.mock("../stores/toasts.svelte.js", () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/LLMConnectionManager.test.js b/frontend/src/lib/settings/LLMConnectionManager.test.js index 04aff39..70950de 100644 --- a/frontend/src/lib/settings/LLMConnectionManager.test.js +++ b/frontend/src/lib/settings/LLMConnectionManager.test.js @@ -17,6 +17,7 @@ vi.mock('../api/ai.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/PackManager.test.js b/frontend/src/lib/settings/PackManager.test.js index 018338b..1b51be4 100644 --- a/frontend/src/lib/settings/PackManager.test.js +++ b/frontend/src/lib/settings/PackManager.test.js @@ -25,6 +25,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/SSOSettings.test.js b/frontend/src/lib/settings/SSOSettings.test.js index 2587492..e352e3f 100644 --- a/frontend/src/lib/settings/SSOSettings.test.js +++ b/frontend/src/lib/settings/SSOSettings.test.js @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; const { ssoStore } = vi.hoisted(() => ({ ssoStore: { @@ -24,6 +24,7 @@ vi.mock('../stores/capabilities.svelte.js', () => ({ vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key, + translateError: (error) => error?.message ?? String(error), })); import SSOSettings from './SSOSettings.svelte'; @@ -230,3 +231,59 @@ describe('SSOSettings attribute mapping', () => { expect(payload.attribute_mapping).toBe('{}'); }); }); + +describe('SSOSettings server base URL awareness', () => { + beforeEach(() => { + vi.clearAllMocks(); + ssoStore.loadProviders.mockResolvedValue(undefined); + ssoStore.subscribe.mockImplementation((subscriber) => { + subscriber({ adminProviders: [] }); + return () => {}; + }); + }); + + afterEach(() => { + delete window.__WINDSHIFT_BASE_URL__; + delete window.__WINDSHIFT_BASE_URL_CONFIGURED__; + }); + + it('shows the server callback URL without a warning when BASE_URL is configured', async () => { + window.__WINDSHIFT_BASE_URL__ = 'https://windshift.example.test'; + window.__WINDSHIFT_BASE_URL_CONFIGURED__ = true; + + render(SSOSettings); + + expect(screen.queryByTestId('sso-base-url-warning')).not.toBeInTheDocument(); + + const addButtons = await screen.findAllByRole('button', { + name: 'settings.sso.addProvider', + }); + await fireEvent.click(addButtons[0]); + const dialog = screen.getByRole('dialog'); + + expect(within(dialog).getByTestId('sso-callback-url')).toHaveTextContent( + 'https://windshift.example.test/api/sso/callback/slug' + ); + expect(within(dialog).queryByTestId('sso-callback-url-warning')).not.toBeInTheDocument(); + }); + + it('warns and shows the fallback callback URL when BASE_URL is not configured', async () => { + window.__WINDSHIFT_BASE_URL__ = 'http://localhost:8080'; + window.__WINDSHIFT_BASE_URL_CONFIGURED__ = false; + + render(SSOSettings); + + expect(screen.getByTestId('sso-base-url-warning')).toBeInTheDocument(); + + const addButtons = await screen.findAllByRole('button', { + name: 'settings.sso.addProvider', + }); + await fireEvent.click(addButtons[0]); + const dialog = screen.getByRole('dialog'); + + expect(within(dialog).getByTestId('sso-callback-url-warning')).toBeInTheDocument(); + expect(within(dialog).getByTestId('sso-callback-url')).toHaveTextContent( + 'http://localhost:8080/api/sso/callback/slug' + ); + }); +}); diff --git a/frontend/src/lib/settings/SecuritySettings.test.js b/frontend/src/lib/settings/SecuritySettings.test.js index 134a0d0..8c16426 100644 --- a/frontend/src/lib/settings/SecuritySettings.test.js +++ b/frontend/src/lib/settings/SecuritySettings.test.js @@ -52,6 +52,7 @@ vi.mock('../stores/i18n.svelte.js', async (importOriginal) => { }); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/SetDetailActions.test.js b/frontend/src/lib/settings/SetDetailActions.test.js new file mode 100644 index 0000000..e769660 --- /dev/null +++ b/frontend/src/lib/settings/SetDetailActions.test.js @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import SetDetailActions from './SetDetailActions.svelte'; + +afterEach(cleanup); + +describe('SetDetailActions', () => { + test('shows the unsaved indicator and saves when there are changes', async () => { + const onSave = vi.fn(); + render(SetDetailActions, { + props: { hasUnsavedChanges: true, onSave, onCancel: vi.fn() }, + }); + + expect(screen.getByText('settings.configSets.unsavedChanges')).toBeInTheDocument(); + const save = screen.getByRole('button', { name: 'common.save' }); + expect(save).toBeEnabled(); + + await fireEvent.click(save); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + test('disables save and hides the indicator without changes', () => { + render(SetDetailActions, { + props: { hasUnsavedChanges: false, onSave: vi.fn(), onCancel: vi.fn() }, + }); + + expect(screen.queryByText('settings.configSets.unsavedChanges')).toBeNull(); + expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled(); + }); + + test('disables save and shows the saving label while saving', () => { + render(SetDetailActions, { + props: { hasUnsavedChanges: true, saving: true, onSave: vi.fn(), onCancel: vi.fn() }, + }); + + expect(screen.getByRole('button', { name: 'common.saving' })).toBeDisabled(); + }); + + test('hides the indicator when asked and forwards the save test id', () => { + render(SetDetailActions, { + props: { + hasUnsavedChanges: true, + showUnsaved: false, + saveTestid: 'approval-set-save', + onSave: vi.fn(), + onCancel: vi.fn(), + }, + }); + + expect(screen.queryByText('settings.configSets.unsavedChanges')).toBeNull(); + expect(screen.getByTestId('approval-set-save')).toBeInTheDocument(); + }); + + test('cancels through the cancel button', async () => { + const onCancel = vi.fn(); + render(SetDetailActions, { + props: { hasUnsavedChanges: true, onSave: vi.fn(), onCancel }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/settings/SetDetailShell.test.js b/frontend/src/lib/settings/SetDetailShell.test.js new file mode 100644 index 0000000..2f9f0e2 --- /dev/null +++ b/frontend/src/lib/settings/SetDetailShell.test.js @@ -0,0 +1,53 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createRawSnippet } from 'svelte'; + +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import SetDetailShell from './SetDetailShell.svelte'; + +const children = createRawSnippet(() => ({ + render: () => '
body
', +})); + +function baseProps(overrides = {}) { + return { + loading: false, + title: 'Set A', + description: 'Set description', + backTooltip: 'Back to sets', + onBack: vi.fn(), + hasUnsavedChanges: false, + saving: false, + onSave: vi.fn(), + children, + ...overrides, + }; +} + +afterEach(cleanup); + +describe('SetDetailShell', () => { + test('renders the header and the content snippet once loaded', () => { + render(SetDetailShell, { props: baseProps() }); + + expect(screen.getByText('Set A')).toBeInTheDocument(); + expect(screen.getByText('Set description')).toBeInTheDocument(); + expect(screen.getByTestId('shell-content')).toBeInTheDocument(); + }); + + test('shows the loading label instead of the content while loading', () => { + render(SetDetailShell, { props: baseProps({ loading: true, loadingLabel: 'loadingâ€Ļ' }) }); + + expect(screen.getByText('loadingâ€Ļ')).toBeInTheDocument(); + expect(screen.queryByTestId('shell-content')).toBeNull(); + }); + + test('navigates back from the header control', async () => { + const onBack = vi.fn(); + render(SetDetailShell, { props: baseProps({ onBack }) }); + + await fireEvent.click(screen.getByRole('button', { name: 'Back to sets' })); + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/settings/ThemeLockupPreview.test.js b/frontend/src/lib/settings/ThemeLockupPreview.test.js new file mode 100644 index 0000000..1d2de5c --- /dev/null +++ b/frontend/src/lib/settings/ThemeLockupPreview.test.js @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +// The component only needs the translation function; mocking i18n keeps the +// locale catalogs out of this focused render test. +vi.mock('../stores/i18n.svelte.js', () => ({ + t: (_key, fallback) => fallback ?? _key, +})); + +import ThemeLockupPreview from './ThemeLockupPreview.svelte'; + +const baseProps = { + siteTitle: 'Acme', + brandLayout: 'mark_text', + logoUrl: '/light.png', + logoUrlDark: '/dark.png', + logoFullUrl: '/full-light.png', + logoFullUrlDark: '/full-dark.png', + lightBackground: '#ffffff', + lightText: '#111827', + darkBackground: '#1f2937', + darkText: '#f3f4f6', +}; + +function preview(testid) { + return screen.getByTestId(`theme-brand-preview-${testid}`); +} + +function imageSrc(testid) { + return preview(testid).querySelector('img')?.getAttribute('src'); +} + +describe('ThemeLockupPreview', () => { + it('shows the mark plus the site title for mark_text in the expanded preview', () => { + render(ThemeLockupPreview, { props: baseProps }); + + expect(imageSrc('light-expanded')).toBe('/light.png'); + expect(preview('light-expanded')).toHaveTextContent('Acme'); + }); + + it('shows only the mark in the collapsed preview for mark_text', () => { + render(ThemeLockupPreview, { props: baseProps }); + + expect(imageSrc('light-collapsed')).toBe('/light.png'); + expect(preview('light-collapsed')).not.toHaveTextContent('Acme'); + }); + + it('shows the full logo without text for full_logo in the expanded preview', () => { + render(ThemeLockupPreview, { props: { ...baseProps, brandLayout: 'full_logo' } }); + + expect(imageSrc('light-expanded')).toBe('/full-light.png'); + expect(preview('light-expanded')).not.toHaveTextContent('Acme'); + }); + + it('uses the dark full logo for the dark expanded preview', () => { + render(ThemeLockupPreview, { props: { ...baseProps, brandLayout: 'full_logo' } }); + + expect(imageSrc('dark-expanded')).toBe('/full-dark.png'); + }); + + it('keeps the mark in the collapsed preview even when full_logo is selected', () => { + render(ThemeLockupPreview, { props: { ...baseProps, brandLayout: 'full_logo' } }); + + expect(imageSrc('light-collapsed')).toBe('/light.png'); + }); + + it('hides the site title for mark_only', () => { + render(ThemeLockupPreview, { props: { ...baseProps, brandLayout: 'mark_only' } }); + + expect(imageSrc('light-expanded')).toBe('/light.png'); + expect(preview('light-expanded')).not.toHaveTextContent('Acme'); + }); + + it('falls back to the mark when full_logo is selected without a full logo', () => { + render(ThemeLockupPreview, { + props: { ...baseProps, brandLayout: 'full_logo', logoFullUrl: '', logoFullUrlDark: '' }, + }); + + expect(imageSrc('light-expanded')).toBe('/light.png'); + }); + + it('falls back to the built-in mark when no logo is set', () => { + render(ThemeLockupPreview, { + props: { + ...baseProps, + logoUrl: '', + logoUrlDark: '', + logoFullUrl: '', + logoFullUrlDark: '', + }, + }); + + expect(imageSrc('light-expanded')).toBe('windshift-3.svg'); + }); +}); diff --git a/frontend/src/lib/settings/ThemeManager.test.js b/frontend/src/lib/settings/ThemeManager.test.js index 8e7d719..316289d 100644 --- a/frontend/src/lib/settings/ThemeManager.test.js +++ b/frontend/src/lib/settings/ThemeManager.test.js @@ -61,6 +61,9 @@ const editableTheme = { nav_text_color_dark: '#f3f4f6', logo_url: '', logo_url_dark: '', + logo_full_url: '', + logo_full_url_dark: '', + brand_layout: 'mark_text', }; function uploadPayload() { @@ -176,6 +179,38 @@ describe('ThemeManager logo upload', () => { }); }); + it('persists the selected brand layout with the theme', async () => { + render(ThemeManager); + await screen.findByText('Acme'); + await fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + + await fireEvent.click(screen.getByTestId('theme-edit-7-brand-layout-full_logo')); + await fireEvent.submit( + screen.getByTestId('theme-edit-7-brand-layout-full_logo').closest('form') + ); + + await waitFor(() => { + expect(themes.update).toHaveBeenCalledWith( + 7, + expect.objectContaining({ brand_layout: 'full_logo' }) + ); + }); + }); + + it('offers SVG on every theme logo slot because the backend accepts it here', async () => { + render(ThemeManager); + await screen.findByText('Acme'); + await fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + // The full logo slots only render for the full_logo layout. + await fireEvent.click(screen.getByTestId('theme-edit-7-brand-layout-full_logo')); + + const fileInputs = document.querySelectorAll('input[type="file"]'); + expect(fileInputs).toHaveLength(4); + for (const fileInput of fileInputs) { + expect(fileInput.getAttribute('accept')).toContain('image/svg+xml'); + } + }); + it('disables uploading when attachments are turned off', async () => { attachmentStatus.hydrate({ enabled: false, writable: true }); @@ -193,4 +228,54 @@ describe('ThemeManager logo upload', () => { expect(fileInput).toBeDisabled(); } }); + + it('shows the full logo uploaders only for the full_logo layout', async () => { + render(ThemeManager); + await screen.findByText('Acme'); + await fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + + // mark_text uses the mark, so the full logo slots are not offered. + expect(screen.queryByText('lookAndFeel.fullLogoLight')).not.toBeInTheDocument(); + expect(document.querySelectorAll('input[type="file"]')).toHaveLength(2); + + await fireEvent.click(screen.getByTestId('theme-edit-7-brand-layout-full_logo')); + + expect(await screen.findByText('lookAndFeel.fullLogoLight')).toBeInTheDocument(); + expect(document.querySelectorAll('input[type="file"]')).toHaveLength(4); + }); + + it('saves the theme from the edit form Save button', async () => { + render(ThemeManager); + await screen.findByText('Acme'); + await fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + + await fireEvent.click(screen.getByTestId('theme-save-7')); + + await waitFor(() => { + expect(themes.update).toHaveBeenCalledWith(7, expect.objectContaining({ name: 'Acme' })); + }); + }); + + it('previews the uploaded full logo only when the full_logo layout is selected', async () => { + themes.getAll.mockResolvedValue([ + { ...editableTheme, logo_full_url: '/api/portal-assets/906' }, + ]); + + render(ThemeManager); + await screen.findByText('Acme'); + await fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + + // mark_text ignores the full logo while no mark is uploaded. + expect( + screen.getByTestId('theme-brand-preview-light-expanded').querySelector('img') + ).toHaveAttribute('src', 'windshift-3.svg'); + + await fireEvent.click(screen.getByTestId('theme-edit-7-brand-layout-full_logo')); + + await waitFor(() => { + expect( + screen.getByTestId('theme-brand-preview-light-expanded').querySelector('img') + ).toHaveAttribute('src', '/api/portal-assets/906'); + }); + }); }); diff --git a/frontend/src/lib/settings/UpdateStatePreservation.test.js b/frontend/src/lib/settings/UpdateStatePreservation.test.js index 8051063..da4871c 100644 --- a/frontend/src/lib/settings/UpdateStatePreservation.test.js +++ b/frontend/src/lib/settings/UpdateStatePreservation.test.js @@ -24,6 +24,7 @@ vi.mock('../api/notifications.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/WorkItemStalenessSettings.test.js b/frontend/src/lib/settings/WorkItemStalenessSettings.test.js index 56dba01..94ca383 100644 --- a/frontend/src/lib/settings/WorkItemStalenessSettings.test.js +++ b/frontend/src/lib/settings/WorkItemStalenessSettings.test.js @@ -9,6 +9,7 @@ vi.mock('../api/ai.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/diagnostics/DomainEventsSection.test.js b/frontend/src/lib/settings/diagnostics/DomainEventsSection.test.js index 2740f7e..b8c1f7b 100644 --- a/frontend/src/lib/settings/diagnostics/DomainEventsSection.test.js +++ b/frontend/src/lib/settings/diagnostics/DomainEventsSection.test.js @@ -8,6 +8,7 @@ vi.mock('../../api/diagnostics.js', () => ({ })); vi.mock('../../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/settings/diagnostics/RecurrenceVolumeSection.test.js b/frontend/src/lib/settings/diagnostics/RecurrenceVolumeSection.test.js index f310573..15be91a 100644 --- a/frontend/src/lib/settings/diagnostics/RecurrenceVolumeSection.test.js +++ b/frontend/src/lib/settings/diagnostics/RecurrenceVolumeSection.test.js @@ -7,6 +7,7 @@ vi.mock("../../api/diagnostics.js", () => ({ })); vi.mock("../../stores/toasts.svelte.js", () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/stores/brand.spec.js b/frontend/src/lib/stores/brand.spec.js new file mode 100644 index 0000000..8b6660c --- /dev/null +++ b/frontend/src/lib/stores/brand.spec.js @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +// resolveBrandLockup is pure; mock the API/toast boundaries so importing the +// store does not pull in network or i18n side effects. +vi.mock('../api/misc.js', () => ({ branding: { get: vi.fn() } })); +vi.mock('./toasts.svelte.js', () => ({ surfaceError: vi.fn() })); + +import { resolveBrandLockup } from './brand.svelte.js'; + +const base = { + siteTitle: 'Acme', + brandLayout: 'mark_text', + logoURL: '/light.png', + logoURLDark: '/dark.png', + logoFullURL: '/full-light.png', + logoFullURLDark: '/full-dark.png', +}; + +describe('resolveBrandLockup', () => { + it('renders the mark with the site title for mark_text', () => { + expect(resolveBrandLockup({ isDark: false, expanded: true }, base)).toEqual({ + src: '/light.png', + variant: 'mark', + showText: true, + text: 'Acme', + }); + }); + + it('uses the dark mark in dark mode', () => { + expect(resolveBrandLockup({ isDark: true, expanded: true }, base).src).toBe('/dark.png'); + }); + + it('falls back to the light mark when no dark mark is set', () => { + expect( + resolveBrandLockup({ isDark: true, expanded: true }, { ...base, logoURLDark: '' }).src + ).toBe('/light.png'); + }); + + it('renders the mark without text for mark_only', () => { + expect( + resolveBrandLockup({ isDark: false, expanded: true }, { ...base, brandLayout: 'mark_only' }) + ).toEqual({ src: '/light.png', variant: 'mark', showText: false, text: 'Acme' }); + }); + + it('renders the full logo without text for full_logo', () => { + expect( + resolveBrandLockup({ isDark: false, expanded: true }, { ...base, brandLayout: 'full_logo' }) + ).toEqual({ src: '/full-light.png', variant: 'full', showText: false, text: 'Acme' }); + }); + + it('uses the dark full logo in dark mode for full_logo', () => { + expect( + resolveBrandLockup( + { isDark: true, expanded: true }, + { ...base, brandLayout: 'full_logo' } + ).src + ).toBe('/full-dark.png'); + }); + + it('falls back to the mark when full_logo has no full logo', () => { + expect( + resolveBrandLockup( + { isDark: false, expanded: true }, + { ...base, brandLayout: 'full_logo', logoFullURL: '' } + ) + ).toEqual({ src: '/light.png', variant: 'mark', showText: false, text: 'Acme' }); + }); + + it('always uses the mark and hides text when collapsed', () => { + expect( + resolveBrandLockup({ isDark: false, expanded: false }, { ...base, brandLayout: 'full_logo' }) + ).toEqual({ src: '/light.png', variant: 'mark', showText: false, text: 'Acme' }); + }); + + it('returns no src when no custom logo is configured', () => { + expect( + resolveBrandLockup( + { isDark: false, expanded: true }, + { ...base, logoURL: '', logoURLDark: '', logoFullURL: '', logoFullURLDark: '' } + ).src + ).toBeNull(); + }); +}); diff --git a/frontend/src/lib/stores/chatStore.svelte.test.js b/frontend/src/lib/stores/chatStore.svelte.test.js index e0961cc..866e3dc 100644 --- a/frontend/src/lib/stores/chatStore.svelte.test.js +++ b/frontend/src/lib/stores/chatStore.svelte.test.js @@ -108,4 +108,32 @@ describe('durable Standard chat selection', () => { expect.objectContaining({ role: 'assistant', content: 'Fresh answer' }), ]); }); + + test('surfaces the admin-only provider error on a failed turn', async () => { + await chatStore.startNewGeneralChat(); + const failure = new Error('The AI service could not complete this request.'); + failure.details = { provider_error: 'temperature is deprecated for this model' }; + ai.chat.mockRejectedValueOnce(failure); + + await chatStore.sendMessage('Please answer'); + + expect(chatStore.messages.at(-1)).toMatchObject({ + role: 'assistant', + error: 'The AI service could not complete this request.', + errorDetail: 'temperature is deprecated for this model', + }); + }); + + test('leaves the provider detail empty when the server omits it', async () => { + await chatStore.startNewGeneralChat(); + ai.chat.mockRejectedValueOnce(new Error('The AI service could not complete this request.')); + + await chatStore.sendMessage('Please answer'); + + expect(chatStore.messages.at(-1)).toMatchObject({ + role: 'assistant', + error: 'The AI service could not complete this request.', + errorDetail: '', + }); + }); }); diff --git a/frontend/src/lib/stores/collectionCompletion.test.js b/frontend/src/lib/stores/collectionCompletion.test.js index 9080457..fae72d9 100644 --- a/frontend/src/lib/stores/collectionCompletion.test.js +++ b/frontend/src/lib/stores/collectionCompletion.test.js @@ -134,17 +134,19 @@ describe("completed item visibility", () => { expect(collectionStore.itemsTotalCount).toBe(0); }); - it("reloads backlog data after visiting a board that included completed work", async () => { + it("reloads backlog without a completion filter after visiting a board that included completed work", async () => { await collectionStore.load("41", null, "workspace-board"); await collectionStore.setShowCompleted(true); await collectionStore.load("41", null, "workspace-list"); mocks.backlog.mockClear(); await collectionStore.load("41", null, "workspace-backlog"); + // Backlog membership comes from the configured backlog statuses, so the + // board's completed-visibility preference must not leak into the request. expect(mocks.backlog).toHaveBeenCalledWith( "41", null, expect.objectContaining({ - sub_ql: "status_completed = false", + sub_ql: undefined, }), ); }); @@ -191,12 +193,11 @@ describe("completed item visibility", () => { } }); - it.each(["list", "tree", "map", "roadmap", "backlog"])( + it.each(["list", "tree", "map", "roadmap"])( "filters %s by completion category before pagination", async (view) => { await collectionStore.load("41", null, `workspace-${view}`); - const fetch = view === "backlog" ? mocks.backlog : mocks.items; - expect(fetch).toHaveBeenLastCalledWith( + expect(mocks.items).toHaveBeenLastCalledWith( "41", null, expect.objectContaining({ @@ -206,6 +207,17 @@ describe("completed item visibility", () => { }, ); + it("does not filter backlog by completion category before pagination", async () => { + await collectionStore.load("41", null, "workspace-backlog"); + expect(mocks.backlog).toHaveBeenLastCalledWith( + "41", + null, + expect.objectContaining({ + sub_ql: undefined, + }), + ); + }); + it.each(["workspace-board", "collection-board"])( "leaves completed visibility to board configuration in %s despite a saved hide preference", async (view) => { @@ -292,4 +304,28 @@ describe("completed item visibility", () => { expect.objectContaining({ sub_ql: "status_completed = false" }), ); }); + + it("keeps the backlog sub-filter without a completion clause across refresh and polling", async () => { + await collectionStore.load("41", null, "workspace-backlog"); + collectionStore.setSubFilter('priority = "High" OR priority = "Low"'); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + const sub_ql = 'priority = "High" OR priority = "Low"'; + expect(mocks.backlog).toHaveBeenLastCalledWith( + "41", + null, + expect.objectContaining({ sub_ql }), + ); + await collectionStore.refresh(); + expect(mocks.backlog).toHaveBeenLastCalledWith( + "41", + null, + expect.objectContaining({ sub_ql }), + ); + await collectionStore.refreshDeltas(); + expect(mocks.changes).toHaveBeenLastCalledWith( + "41", + null, + expect.objectContaining({ sub_ql }), + ); + }); }); diff --git a/frontend/src/lib/stores/collectionContext.test.js b/frontend/src/lib/stores/collectionContext.test.js index f421a7f..d14cffe 100644 --- a/frontend/src/lib/stores/collectionContext.test.js +++ b/frontend/src/lib/stores/collectionContext.test.js @@ -9,8 +9,36 @@ const mocks = vi.hoisted(() => ({ fetchCollectionTotal: vi.fn().mockResolvedValue({ total: 300, watermark: 100 }), getBoardConfigurationBootstrap: vi.fn(), getCollection: vi.fn(), + getLevelCounts: vi.fn().mockResolvedValue([]), + preferences: { viewState: {} }, })); +vi.mock('../api/items.js', () => ({ + items: { getLevelCounts: mocks.getLevelCounts }, +})); + +vi.mock('./collectionPreferences.svelte.js', () => { + const scopeId = (ws, col) => (col ? `collection-${col}` : `workspace-${ws ?? 'global'}`); + return { + collectionPreferences: { + getViewState(ws, col, view) { + return mocks.preferences.viewState[scopeId(ws, col)]?.[view] ?? null; + }, + patchViewState(ws, col, view, patch) { + const scope = scopeId(ws, col); + const currentScope = mocks.preferences.viewState[scope] ?? {}; + mocks.preferences.viewState = { + ...mocks.preferences.viewState, + [scope]: { + ...currentScope, + [view]: { ...(currentScope[view] ?? {}), ...patch }, + }, + }; + }, + }, + }; +}); + vi.mock('../api/collections.js', () => ({ collections: { getBoardConfigurationBootstrap: mocks.getBoardConfigurationBootstrap, @@ -501,7 +529,7 @@ describe('CollectionStore board ordering', () => { ); }); - it('scopes the current_iteration preset to the active iteration', async () => { + it('caps the completed fetch at the configured limit', async () => { mocks.fetchCollectionItems.mockClear(); mocks.fetchCollectionBacklog.mockResolvedValue({ items: [], @@ -512,47 +540,44 @@ describe('CollectionStore board ordering', () => { board_configuration: { columns: [], show_rightmost_column_last_50: false, - view_settings: { completed_visibility: 'current_iteration' }, + view_settings: { completed_visibility: 'limit', completed_limit: 50 }, }, statuses: [ { id: 1, name: 'Open', category_name: 'To Do', is_completed: false }, { id: 2, name: 'Done', category_name: 'Done', is_completed: true }, ], - referenced_workspace_ids: [73], + referenced_workspace_ids: [74], }); mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => { if (options.status_id_not === '2') { return Promise.resolve({ items: [{ id: 1, status_id: 1 }], - collectionName: 'Iteration window', + collectionName: 'Limited done', pagination: { page: 1, page_size: 1000, total_items: 1, total_pages: 1 }, watermark: 7, }); } if (options.status_id === '2') { return Promise.resolve({ - items: [{ id: 2, status_id: 2 }], - collectionName: 'Iteration window', - pagination: { page: 1, page_size: 100, total_items: 1, total_pages: 1 }, + items: Array.from({ length: 50 }, (_, index) => ({ id: 100 + index, status_id: 2 })), + collectionName: 'Limited done', + pagination: { page: 1, page_size: 50, total_items: 900, total_pages: 18 }, watermark: 7, }); } throw new Error(`unexpected item request: ${JSON.stringify(options)}`); }); - mocks.routeSubscriber({ view: 'workspace-board', params: { id: '73' } }); + mocks.routeSubscriber({ view: 'workspace-board', params: { id: '74' } }); await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); - mocks.fetchCollectionItems.mockClear(); - - collectionStore.setBoardCompletedIteration(55); - await vi.waitFor(() => - expect(mocks.fetchCollectionItems).toHaveBeenCalledWith( - '73', - null, - expect.objectContaining({ status_id: '2', iteration_id: 55 }) - ) + expect(mocks.fetchCollectionItems).toHaveBeenCalledWith( + '74', + null, + expect.objectContaining({ status_id: '2', limit: 50 }) ); + // The cap is the whole completed set, so the board never pages past it. + expect(collectionStore.itemsHasMore).toBe(false); }); it('searches the complete collection scope without board trimming filters', async () => { @@ -1192,6 +1217,254 @@ describe('CollectionStore hierarchy continuation', () => { expect(collectionStore.itemsHasMore).toBe(false); expect(collectionStore.itemsRemainingCount).toBe(0); }); + + it('refreshes an accumulated tree from page 1 instead of offsetting past the loaded set', async () => { + collectionStore.invalidate(); + mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => { + const limit = options.limit ?? 100; + const page = options.page ?? 1; + const total = 150; + const offset = (page - 1) * limit; + const count = Math.max(0, Math.min(limit, total - offset)); + return Promise.resolve({ + items: Array.from({ length: count }, (_, index) => ({ + id: offset + index + 1, + status_id: 1, + })), + collectionName: 'Big tree', + pagination: { + page, + page_size: limit, + limit, + total_items: total, + total_pages: Math.ceil(total / limit), + }, + watermark: 1, + }); + }); + mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + + mocks.routeSubscriber({ view: 'workspace-tree', params: { id: '88' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + expect(collectionStore.items).toHaveLength(100); + + await collectionStore.loadMoreItems(); + expect(collectionStore.items).toHaveLength(150); + + await collectionStore.refresh(); + + // Reloading page 2 with limit 150 would offset to item 150 and blank the + // tree; an accumulated hierarchy must reload from page 1. + expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( + '88', + null, + expect.objectContaining({ page: 1, limit: 150 }) + ); + expect(collectionStore.items).toHaveLength(150); + }); + + it('completes a hierarchy refresh past the 1000-item request cap', async () => { + collectionStore.invalidate(); + mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => { + const limit = Math.min(options.limit ?? 100, 1000); + const page = options.page ?? 1; + const total = 1200; + const offset = (page - 1) * limit; + const count = Math.max(0, Math.min(limit, total - offset)); + return Promise.resolve({ + items: Array.from({ length: count }, (_, index) => ({ + id: offset + index + 1, + status_id: 1, + })), + collectionName: 'Huge tree', + pagination: { + page, + page_size: limit, + limit, + total_items: total, + total_pages: Math.ceil(total / limit), + }, + watermark: 1, + }); + }); + mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + + mocks.routeSubscriber({ view: 'workspace-tree', params: { id: '99' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + for (let index = 0; index < 11; index += 1) { + await collectionStore.loadMoreItems(); + } + expect(collectionStore.items).toHaveLength(1200); + + await collectionStore.refresh(); + + expect(collectionStore.items).toHaveLength(1200); + expect(collectionStore.items.at(-1)).toMatchObject({ id: 1200 }); + }); +}); + +describe('CollectionStore hierarchy level scope', () => { + afterEach(() => { + vi.restoreAllMocks(); + collectionStore.collectionTotal = null; + collectionStore.hierarchyLevel = null; + }); + + it('scopes hierarchy item reads to the selected level and opts into has_children', async () => { + collectionStore.invalidate(); + mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => + Promise.resolve({ + items: [{ id: 1, status_id: 1 }], + collectionName: 'Tree', + pagination: { page: options.page ?? 1, page_size: 100, limit: 100, total_items: 1, total_pages: 1 }, + watermark: 1, + }) + ); + mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + + mocks.routeSubscriber({ view: 'workspace-tree', params: { id: '100' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + + collectionStore.setHierarchyLevel(2); + await vi.waitFor(() => + expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( + '100', + null, + expect.objectContaining({ level: 2, include_has_children: true }) + ) + ); + + // A non-hierarchy view never carries the level scope. + collectionStore.invalidate(); + mocks.routeSubscriber({ view: 'workspace-list', params: { id: '101' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( + '101', + null, + expect.not.objectContaining({ level: expect.anything() }) + ); + }); + + it('reuses the collection and count when only the hierarchy level changes', async () => { + collectionStore.invalidate(); + mocks.getCollection.mockResolvedValue({ id: 55, name: 'Tree', ql_query: '' }); + mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => + Promise.resolve({ + items: [{ id: 1, status_id: 1 }], + collectionName: 'Tree', + pagination: { page: options.page ?? 1, page_size: 100, limit: 100, total_items: 1, total_pages: 1 }, + watermark: 1, + }) + ); + mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + + mocks.routeSubscriber({ view: 'workspace-tree', params: { id: '100', collectionId: '55' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + await vi.waitFor(() => expect(collectionStore.collectionTotal).toBe(300)); + + const collectionCalls = mocks.getCollection.mock.calls.length; + const totalCalls = mocks.fetchCollectionTotal.mock.calls.length; + + collectionStore.setHierarchyLevel(2); + await vi.waitFor(() => + expect(mocks.fetchCollectionItems).toHaveBeenLastCalledWith( + '100', + '55', + expect.objectContaining({ level: 2 }) + ) + ); + + // The level only re-scopes the item page: the collection metadata and the + // unfiltered count are unchanged and must not be re-read. + expect(mocks.getCollection).toHaveBeenCalledTimes(collectionCalls); + expect(mocks.fetchCollectionTotal).toHaveBeenCalledTimes(totalCalls); + }); +}); + +describe('CollectionStore hierarchy level resolution', () => { + function levelCountsResult(levels) { + return levels.map((level) => ({ level, name: `Level ${level}`, total: 1 })); + } + + beforeEach(() => { + collectionStore.invalidate(); + mocks.getLevelCounts.mockReset().mockResolvedValue([]); + mocks.preferences.viewState = {}; + mocks.fetchCollectionItems.mockImplementation((_workspaceId, _collectionId, options) => + Promise.resolve({ + items: [{ id: 1, status_id: 1 }], + collectionName: 'Hierarchy', + pagination: { + page: options.page ?? 1, + page_size: options.limit ?? 100, + limit: options.limit ?? 100, + total_items: 1, + total_pages: 1, + }, + watermark: 1, + }) + ); + mocks.getBoardConfigurationBootstrap.mockResolvedValue({ board_configuration: null }); + }); + + afterEach(() => { + collectionStore.hierarchyLevel = null; + collectionStore.levelCounts = []; + mocks.preferences.viewState = {}; + }); + + it('applies the resolved default level to the first item fetch', async () => { + mocks.getLevelCounts.mockResolvedValue( + levelCountsResult([0, 1]).map((entry, index) => ({ ...entry, total: index })) + ); + + mocks.routeSubscriber({ view: 'workspace-map', params: { id: '201' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + + // The level resolves before the item page, so no unscoped page is issued. + const itemCalls = mocks.fetchCollectionItems.mock.calls; + expect(itemCalls.length).toBeGreaterThan(0); + for (const [, , options] of itemCalls) { + expect(options).toMatchObject({ level: 1, include_has_children: true }); + } + expect(collectionStore.hierarchyLevel).toBe(1); + expect(mocks.preferences.viewState['workspace-201'].map.hierarchy_level).toBe(1); + }); + + it('reuses level counts across hierarchy views in one scope', async () => { + mocks.getLevelCounts.mockResolvedValue(levelCountsResult([1])); + + mocks.routeSubscriber({ view: 'workspace-map', params: { id: '202' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + expect(mocks.getLevelCounts).toHaveBeenCalledTimes(1); + + // Switching to another hierarchy view in the same scope must not re-issue + // the lookup, and the resolved level still applies. + collectionStore.invalidate(); + mocks.routeSubscriber({ view: 'workspace-tree', params: { id: '202' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + expect(mocks.getLevelCounts).toHaveBeenCalledTimes(1); + expect(collectionStore.hierarchyLevel).toBe(1); + }); + + it('does not reuse a level or counts across workspace boundaries', async () => { + mocks.getLevelCounts + .mockResolvedValueOnce(levelCountsResult([1])) + .mockResolvedValueOnce(levelCountsResult([2])); + + mocks.routeSubscriber({ view: 'workspace-map', params: { id: '210' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + expect(collectionStore.hierarchyLevel).toBe(1); + + collectionStore.invalidate(); + mocks.routeSubscriber({ view: 'workspace-map', params: { id: '211' } }); + await vi.waitFor(() => expect(collectionStore.loading).toBe(false)); + + expect(collectionStore.hierarchyLevel).toBe(2); + expect(mocks.getLevelCounts).toHaveBeenCalledTimes(2); + expect(mocks.preferences.viewState['workspace-210'].map.hierarchy_level).toBe(1); + expect(mocks.preferences.viewState['workspace-211'].map.hierarchy_level).toBe(2); + }); }); describe('CollectionStore single-writer mutations', () => { diff --git a/frontend/src/lib/stores/collectionCounts.test.js b/frontend/src/lib/stores/collectionCounts.test.js index 8c77f48..e0a8db3 100644 --- a/frontend/src/lib/stores/collectionCounts.test.js +++ b/frontend/src/lib/stores/collectionCounts.test.js @@ -5,7 +5,12 @@ import { collections } from '../api/collections.js'; const mocks = vi.hoisted(() => ({ list: vi.fn(), backlog: vi.fn(), changes: vi.fn() })); vi.mock('../api/items.js', () => ({ - items: { getAll: mocks.list, getBacklog: mocks.backlog, getChanges: mocks.changes }, + items: { + getAll: mocks.list, + getBacklog: mocks.backlog, + getChanges: mocks.changes, + getLevelCounts: vi.fn().mockResolvedValue([]), + }, })) vi.mock('../api/collections.js', () => ({ diff --git a/frontend/src/lib/stores/collectionPreferences.test.js b/frontend/src/lib/stores/collectionPreferences.test.js index 34f24af..689c414 100644 --- a/frontend/src/lib/stores/collectionPreferences.test.js +++ b/frontend/src/lib/stores/collectionPreferences.test.js @@ -153,4 +153,74 @@ describe('collectionPreferences store', () => { expect(mocks.get).toHaveBeenCalledTimes(2); expect(collectionPreferences.getViewState(1, 5, 'board')).toBeNull(); }); + + it('hydrates the comment sort order from the same preferences load', async () => { + mocks.get.mockResolvedValue({ + view_state: {}, + saved_filters: {}, + comment_sort_order: 'newest', + }); + + await collectionPreferences.ensureLoaded(1); + + expect(collectionPreferences.commentSortOrder).toBe('newest'); + expect(mocks.get).toHaveBeenCalledTimes(1); + }); + + it('defaults the comment sort order to oldest for missing or unknown values', async () => { + await collectionPreferences.ensureLoaded(1); + expect(collectionPreferences.commentSortOrder).toBe('oldest'); + + collectionPreferences.reset(); + mocks.get.mockResolvedValue({ comment_sort_order: 'sideways' }); + await collectionPreferences.ensureLoaded(1); + + expect(collectionPreferences.commentSortOrder).toBe('oldest'); + }); + + it('persists a changed comment sort order without touching view state', async () => { + await collectionPreferences.ensureLoaded(1); + + collectionPreferences.setCommentSortOrder('newest'); + await collectionPreferences.flush(); + + expect(collectionPreferences.commentSortOrder).toBe('newest'); + expect(mocks.update).toHaveBeenCalledWith({ comment_sort_order: 'newest' }); + }); + + it('does not write when the comment sort order is unchanged', async () => { + await collectionPreferences.ensureLoaded(1); + + collectionPreferences.setCommentSortOrder('oldest'); + await collectionPreferences.flush(); + + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it('retries a failed comment sort order save', async () => { + await collectionPreferences.ensureLoaded(1); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mocks.update.mockRejectedValueOnce(new Error('offline')); + + collectionPreferences.setCommentSortOrder('newest'); + await collectionPreferences.flush(); + expect(mocks.update).toHaveBeenCalledTimes(1); + + mocks.update.mockResolvedValue({}); + await collectionPreferences.flush(); + warn.mockRestore(); + + expect(mocks.update).toHaveBeenCalledTimes(2); + expect(mocks.update).toHaveBeenLastCalledWith({ comment_sort_order: 'newest' }); + }); + + it('clears the comment sort order on reset', async () => { + mocks.get.mockResolvedValue({ comment_sort_order: 'newest' }); + await collectionPreferences.ensureLoaded(1); + expect(collectionPreferences.commentSortOrder).toBe('newest'); + + collectionPreferences.reset(); + + expect(collectionPreferences.commentSortOrder).toBe('oldest'); + }); }); diff --git a/frontend/src/lib/stores/extensions.test.js b/frontend/src/lib/stores/extensions.test.js new file mode 100644 index 0000000..3ed5f96 --- /dev/null +++ b/frontend/src/lib/stores/extensions.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { EDITOR_MARKDOWN_POINT, getEditorProviders } from './extensions.svelte.js'; + +describe('getEditorProviders', () => { + const data = { + [EDITOR_MARKDOWN_POINT]: [ + { id: 'b', pluginName: 'p', entityTypes: ['item'], order: 20 }, + { id: 'a', pluginName: 'p', entityTypes: ['item', 'page'], order: 10 }, + { id: 'any', pluginName: 'p', order: 5 }, + { id: 'page-only', pluginName: 'p', entityTypes: ['page'], order: 1 }, + ], + }; + + it('filters by entity type and sorts by order', () => { + expect(getEditorProviders(data, 'item').map((provider) => provider.id)).toEqual([ + 'any', + 'a', + 'b', + ]); + }); + + it('treats a provider without entityTypes as applying to every surface', () => { + expect(getEditorProviders(data, 'comment').map((provider) => provider.id)).toEqual(['any']); + }); + + it('returns every provider when no entity type is given', () => { + expect(getEditorProviders(data, null).map((provider) => provider.id)).toEqual([ + 'page-only', + 'any', + 'a', + 'b', + ]); + }); + + it('returns an empty list when the point is absent', () => { + expect(getEditorProviders({}, 'item')).toEqual([]); + expect(getEditorProviders(null, 'item')).toEqual([]); + }); + + it('does not mutate the source list', () => { + const before = data[EDITOR_MARKDOWN_POINT].map((provider) => provider.id); + getEditorProviders(data, 'item'); + expect(data[EDITOR_MARKDOWN_POINT].map((provider) => provider.id)).toEqual(before); + }); +}); diff --git a/frontend/src/lib/stores/i18n.svelte.test.js b/frontend/src/lib/stores/i18n.svelte.test.js new file mode 100644 index 0000000..e0da0ac --- /dev/null +++ b/frontend/src/lib/stores/i18n.svelte.test.js @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { i18n, SUPPORTED_LOCALES, t } from './i18n.svelte.js'; + +const PERSIAN_RANGE = /[\u0600-\u06FF]/; + +describe('Persian locale registration', () => { + beforeEach(async () => { + await i18n.setLocale('en'); + }); + + afterEach(() => { + document.documentElement.dir = ''; + document.documentElement.lang = ''; + localStorage.clear(); + }); + + it('is listed as a supported right-to-left locale', () => { + expect(SUPPORTED_LOCALES.find((locale) => locale.code === 'fa')).toEqual({ + code: 'fa', + name: 'ŲØ§ØąØŗÛŒ', + direction: 'rtl', + }); + }); + + it('loads the Persian catalog and applies right-to-left document direction', async () => { + await i18n.setLocale('fa'); + + expect(i18n.locale).toBe('fa'); + expect(i18n.direction).toBe('rtl'); + expect(i18n.isRTL).toBe(true); + expect(document.documentElement.dir).toBe('rtl'); + expect(document.documentElement.lang).toBe('fa'); + + const save = t('common.save'); + expect(save).not.toBe('Save'); + expect(save).toMatch(PERSIAN_RANGE); + }); +}); + +describe('Polish locale registration', () => { + beforeEach(async () => { + await i18n.setLocale('en'); + }); + + afterEach(() => { + document.documentElement.dir = ''; + document.documentElement.lang = ''; + localStorage.clear(); + }); + + it('is listed as a supported left-to-right locale', () => { + expect(SUPPORTED_LOCALES.find((locale) => locale.code === 'pl')).toEqual({ + code: 'pl', + name: 'Polski', + direction: 'ltr', + }); + }); + + it('loads the Polish catalog and applies left-to-right document direction', async () => { + await i18n.setLocale('pl'); + + expect(i18n.locale).toBe('pl'); + expect(i18n.direction).toBe('ltr'); + expect(i18n.isRTL).toBe(false); + expect(document.documentElement.dir).toBe('ltr'); + expect(document.documentElement.lang).toBe('pl'); + + expect(t('common.save')).toBe('Zapisz'); + expect(t('common.loading')).toContain('Ł'); + expect(t('workspaces.workspaces', { count: 3 })).toBe('3 przestrzenie robocze'); + }); +}); diff --git a/frontend/src/lib/stores/itemDetailStore.test.js b/frontend/src/lib/stores/itemDetailStore.test.js index 9135b1b..5a8fafb 100644 --- a/frontend/src/lib/stores/itemDetailStore.test.js +++ b/frontend/src/lib/stores/itemDetailStore.test.js @@ -626,6 +626,66 @@ describe('itemDetailStore.loadItem request graph', () => { }); }); +describe('itemDetailStore same-item reconcile', () => { + beforeEach(() => { + itemDetailStore.reset(); + workspaceDataStore.reset(); + vi.clearAllMocks(); + mockSuccessfulRelatedLoads(); + }); + + it('keeps the child list rendered while a same-item reconcile is in flight', async () => { + const children = [ + { + id: 7, + workspace_id: 1, + workspace_key: 'WS', + workspace_item_number: 101, + item_type_id: 5, + title: 'Child item', + status_id: 1, + status_name: 'Open', + status_color: '#94a3b8', + frac_index: 'a0', + }, + ]; + const item = { + id: 42, + workspace_id: 1, + title: 'Item', + parent_id: null, + item_type_id: null, + request_type_id: null, + }; + items.getDetailSummary.mockResolvedValue(detailSummary(item, { children })); + + await itemDetailStore.loadItem(1, 42); + const childRef = itemDetailStore.childItems; + expect(childRef).toEqual(children); + + // Hold the reconcile's summary open to observe the in-flight state. + let release; + items.getDetailSummary.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }) + ); + const reconcile = itemDetailStore.loadItem(1, 42); + await vi.waitFor(() => expect(items.getDetailSummary).toHaveBeenCalledTimes(2)); + + // A background reconcile must not flash the child-items loading state or + // replace an unchanged list. + expect(itemDetailStore.loadingChildItems).toBe(false); + expect(itemDetailStore.childItems).toBe(childRef); + + release(detailSummary(item, { children })); + await reconcile; + + expect(itemDetailStore.loadingChildItems).toBe(false); + expect(itemDetailStore.childItems).toBe(childRef); + }); +}); + describe('itemDetailStore.loadChildItems', () => { beforeEach(() => { itemDetailStore.reset(); @@ -808,6 +868,36 @@ describe('itemDetailStore.saveField', () => { expect(itemDetailStore.availableStatusTransitions).toEqual(nextTransitions); }); + it('replays a queued status transition with its screen fields intact', async () => { + itemDetailStore.item = { id: 42, status_id: 1 }; + let resolveFirst; + items.update.mockImplementationOnce( + (_id, data) => + new Promise((resolve) => { + resolveFirst = () => resolve({ ...data }); + }) + ); + items.transition.mockResolvedValue({ id: 42, status_id: 2 }); + items.getAvailableStatusTransitions.mockResolvedValue({ available_transitions: [] }); + + // A field save holds the store busy. + const first = itemDetailStore.saveField('estimate_minutes', 240); + // The transition-screen confirm lands while that save is in flight; the + // queued replay must carry the submitted screen fields, not drop them. + const second = itemDetailStore.saveField('status_id', 2, null, null, null, { + comment: 'moving along', + label_ids: [5], + }); + + resolveFirst(); + await Promise.all([first, second]); + + expect(items.transition).toHaveBeenCalledWith(42, 2, { + comment: 'moving along', + label_ids: [5], + }); + }); + it('persists a team assignment and optimistically updates the team name', async () => { itemDetailStore.item = { id: 42, team_id: null, team_name: null }; diff --git a/frontend/src/lib/stores/logbook.test.js b/frontend/src/lib/stores/logbook.test.js deleted file mode 100644 index 5bd5add..0000000 --- a/frontend/src/lib/stores/logbook.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { logbook } from '../api/logbook.js'; - - -vi.mock('../api/logbook.js', () => ({ - logbook: { - listDocuments: vi.fn(), - listAllDocuments: vi.fn(), - }, -})); - - -const { logbookStore } = await import("./logbook.svelte.js"); - -function deferred() { - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe("logbook store document ownership (WI-1615)", () => { - beforeEach(() => { - vi.clearAllMocks(); - logbookStore.resetDocuments?.(); - }); - - it("keeps the newest bucket documents when an older response resolves last", async () => { - const first = deferred(); - const second = deferred(); - logbook.listDocuments - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise); - - const firstLoad = logbookStore.loadDocuments(1); - const secondLoad = logbookStore.loadDocuments(2); - - second.resolve({ data: [{ id: 20, title: "Beta" }], pagination: { total: 1 } }); - await secondLoad; - expect(logbookStore.documents).toEqual([{ id: 20, title: "Beta" }]); - expect(logbookStore.totalDocuments).toBe(1); - - // The obsolete first response must not replace the current list. - first.resolve({ data: [{ id: 10, title: "Alpha" }], pagination: { total: 1 } }); - await firstLoad; - expect(logbookStore.documents).toEqual([{ id: 20, title: "Beta" }]); - expect(logbookStore.totalDocuments).toBe(1); - }); - - it("ignores a superseded rejection and keeps loading state owned by the newest load", async () => { - const first = deferred(); - const second = deferred(); - logbook.listDocuments - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise); - - const firstLoad = logbookStore.loadDocuments(1); - const secondLoad = logbookStore.loadDocuments(2); - - second.resolve({ data: [{ id: 20 }], pagination: { total: 1 } }); - await secondLoad; - expect(logbookStore.documentsLoading).toBe(false); - - first.reject(new Error("stale failure")); - await firstLoad; - expect(logbookStore.documents).toEqual([{ id: 20 }]); - expect(logbookStore.documentsLoading).toBe(false); - }); - - it("invalidates an in-flight bucket load when switching to all documents", async () => { - const bucketLoad = deferred(); - const allLoad = deferred(); - logbook.listDocuments.mockImplementationOnce(() => bucketLoad.promise); - logbook.listAllDocuments.mockImplementationOnce(() => allLoad.promise); - - const firstLoad = logbookStore.loadDocuments(1); - const secondLoad = logbookStore.loadAllDocuments(); - - allLoad.resolve({ data: [{ id: 30 }], pagination: { total: 1 } }); - await secondLoad; - bucketLoad.resolve({ data: [{ id: 10 }], pagination: { total: 1 } }); - await firstLoad; - - expect(logbookStore.documents).toEqual([{ id: 30 }]); - expect(logbookStore.activeBucketId).toBe(null); - }); - - it("resetDocuments drops an in-flight load so it cannot commit later", async () => { - const pending = deferred(); - logbook.listDocuments.mockImplementationOnce(() => pending.promise); - - const load = logbookStore.loadDocuments(1); - logbookStore.resetDocuments(); - pending.resolve({ data: [{ id: 10 }], pagination: { total: 1 } }); - await load; - - expect(logbookStore.documents).toEqual([]); - expect(logbookStore.totalDocuments).toBe(0); - }); -}); diff --git a/frontend/src/lib/stores/notifications.test.js b/frontend/src/lib/stores/notifications.test.js index 2b7cbbb..7f8ef1d 100644 --- a/frontend/src/lib/stores/notifications.test.js +++ b/frontend/src/lib/stores/notifications.test.js @@ -6,6 +6,7 @@ vi.mock('../api/notifications.js', () => ({ notifications: { getAll: vi.fn(() => Promise.resolve([])), clearAll: vi.fn(() => Promise.resolve()), + dismiss: vi.fn(() => Promise.resolve()), markAsRead: vi.fn(() => Promise.resolve()), markAllAsRead: vi.fn(() => Promise.resolve()), markItemAsRead: vi.fn(() => Promise.resolve()), @@ -21,7 +22,7 @@ vi.mock('../utils/dateFormatter.js', () => ({ vi.mock('../utils/serverClock.js', () => ({ serverNow: vi.fn(() => new Date('2026-05-12T12:00:00Z')), })); -vi.mock('./toasts.svelte.js', () => ({ addToast: vi.fn() })); +vi.mock('./toasts.svelte.js', () => ({ surfaceError: vi.fn(), addToast: vi.fn() })); import { notifications as notificationsApi } from '../api/notifications.js'; import { notificationActions, notifications, startNotificationPoller, stopNotificationPoller } from './notifications.js'; @@ -177,23 +178,25 @@ describe('notificationActions.markItemAsRead', () => { }); describe('notificationActions.dismiss', () => { - test('removes the matching notification from local state', () => { + test('deletes the notification on the server and removes it from local state', async () => { notifications.set([ { id: 1, read: false }, { id: 2, read: false }, { id: 3, read: true }, ]); - notificationActions.dismiss(2); + await notificationActions.dismiss(2); + expect(notificationsApi.dismiss).toHaveBeenCalledWith(2); expect(get(notifications)).toEqual([ { id: 1, read: false }, { id: 3, read: true }, ]); }); - test('does not call the API (local-only dismissal)', () => { + test('keeps the notification when the server delete fails', async () => { + notificationsApi.dismiss.mockRejectedValueOnce(new Error('500')); notifications.set([{ id: 1, read: false }]); - notificationActions.dismiss(1); - expect(notificationsApi.markAsRead).not.toHaveBeenCalled(); + await notificationActions.dismiss(1); + expect(get(notifications)).toEqual([{ id: 1, read: false }]); }); }); diff --git a/frontend/src/lib/stores/permissions.test.js b/frontend/src/lib/stores/permissions.test.js index 29c9713..7e74c16 100644 --- a/frontend/src/lib/stores/permissions.test.js +++ b/frontend/src/lib/stores/permissions.test.js @@ -42,7 +42,6 @@ beforeEach(() => { permissionStore.setHasActivePortals(false); permissionStore.setManagesChannels(false); permissionStore.setManagesAssetSets(false); - permissionStore.setLogbookAvailable(false); vi.clearAllMocks(); }); @@ -165,19 +164,6 @@ describe('canAccessPortalHub', () => { }); }); -describe('canAccessLogbook', () => { - test('requires logbook availability + authenticated user', () => { - authStore.set({ currentUser: { id: 1 } }); - expect(get(permissionStore).canAccessLogbook).toBe(false); - - permissionStore.setLogbookAvailable(true); - expect(get(permissionStore).canAccessLogbook).toBe(true); - - authStore.set({ currentUser: null }); - expect(get(permissionStore).canAccessLogbook).toBe(false); - }); -}); - describe('canManageAssets', () => { test('false without user', () => { expect(get(permissionStore).canManageAssets).toBe(false); diff --git a/frontend/src/lib/stores/portal.test.js b/frontend/src/lib/stores/portal.test.js index d4eb811..7d081f5 100644 --- a/frontend/src/lib/stores/portal.test.js +++ b/frontend/src/lib/stores/portal.test.js @@ -68,6 +68,7 @@ vi.mock('./portalAuth.svelte.js', () => ({ })); vi.mock('./toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/stores/portalActivity.test.js b/frontend/src/lib/stores/portalActivity.test.js index 88200bf..6ccbb13 100644 --- a/frontend/src/lib/stores/portalActivity.test.js +++ b/frontend/src/lib/stores/portalActivity.test.js @@ -44,7 +44,7 @@ vi.mock('./portalAuth.svelte.js', () => ({ portalAuthStore: { isAuthenticated: false }, })); -vi.mock('./toasts.svelte.js', () => ({ errorToast: vi.fn() })); +vi.mock('./toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: vi.fn() })); import { configurePortalActivityStore, portalActivityStore, portalApprovalsStore, portalDraftsStore, portalRequestsStore } from './portalActivity.svelte.js'; diff --git a/frontend/src/lib/stores/referenceDataInvalidation.test.js b/frontend/src/lib/stores/referenceDataInvalidation.test.js new file mode 100644 index 0000000..deb89c4 --- /dev/null +++ b/frontend/src/lib/stores/referenceDataInvalidation.test.js @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The module only needs the event name from the API layer; mocking keeps the +// test off the whole request stack. +vi.mock('../api/core.js', () => ({ + API_MUTATION_EVENT: 'windshift:api-mutation', +})); + +// The real stores are the runtime defaults; the test injects fakes instead. +vi.mock('./workspaceDataStore.svelte.js', () => ({ workspaceDataStore: {} })); +vi.mock('./collectionEditorOptions.svelte.js', () => ({ collectionEditorOptions: {} })); + +const { referenceRulesForEndpoint, applyReferenceInvalidation } = await import( + './referenceDataInvalidation.js' +); + +function fakeCaches(workspaceId = 5) { + return { + store: { + workspaceId, + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateScreenConfigs: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined), + }, + editorOptions: { + invalidate: vi.fn(), + invalidateFamily: vi.fn(), + reset: vi.fn(), + }, + }; +} + +describe('referenceRulesForEndpoint', () => { + it('ignores endpoints that are not reference data', () => { + expect(referenceRulesForEndpoint('/items/12')).toEqual([]); + expect(referenceRulesForEndpoint('/items/12/comments')).toEqual([]); + expect(referenceRulesForEndpoint('/workspaces/3/collections/2/items')).toEqual([]); + expect(referenceRulesForEndpoint('')).toEqual([]); + expect(referenceRulesForEndpoint(undefined)).toEqual([]); + }); + + it('matches workspace-scoped and global reference endpoints', () => { + expect(referenceRulesForEndpoint('/workspaces/5/milestones/9')).toHaveLength(1); + expect(referenceRulesForEndpoint('/milestones')).toHaveLength(1); + expect(referenceRulesForEndpoint('/workspaces/5/agent-profiles')).toHaveLength(1); + expect(referenceRulesForEndpoint('/admin/groups/4')).toHaveLength(1); + expect(referenceRulesForEndpoint('/time/projects/7')).toHaveLength(1); + }); +}); + +describe('applyReferenceInvalidation', () => { + let caches; + + beforeEach(() => { + caches = fakeCaches(); + }); + + it('clears the scoped store field and editor family for a workspace mutation', () => { + expect(applyReferenceInvalidation('/workspaces/5/milestones/9', caches)).toBe(true); + expect(caches.store.invalidate).toHaveBeenCalledWith('milestones'); + expect(caches.editorOptions.invalidate).toHaveBeenCalledWith(5, 'milestones'); + expect(caches.editorOptions.invalidateFamily).not.toHaveBeenCalled(); + }); + + it('clears every scoped editor cache for a global mutation', () => { + expect(applyReferenceInvalidation('/milestones', caches)).toBe(true); + expect(caches.store.invalidate).toHaveBeenCalledWith('milestones'); + expect(caches.editorOptions.invalidateFamily).toHaveBeenCalledWith('milestones'); + expect(caches.editorOptions.invalidate).not.toHaveBeenCalled(); + }); + + it('refreshes the active workspace store even when the active scope differs', () => { + caches.store.workspaceId = 7; + applyReferenceInvalidation('/workspaces/5/iterations', caches); + expect(caches.store.invalidate).toHaveBeenCalledWith('iterations'); + expect(caches.editorOptions.invalidate).toHaveBeenCalledWith(5, 'iterations'); + }); + + it('drops effective screen configs for config mutations', () => { + applyReferenceInvalidation('/workspaces/5/item-types/2', caches); + expect(caches.store.invalidate).toHaveBeenCalledWith('itemTypes'); + expect(caches.store.invalidateScreenConfigs).toHaveBeenCalled(); + }); + + it('refreshes the workspace record on a workspace update', () => { + applyReferenceInvalidation('/workspaces/5', caches); + expect(caches.store.invalidate).toHaveBeenCalledWith('workspace'); + }); + + it('treats agent profiles as user-directory changes', () => { + applyReferenceInvalidation('/workspaces/5/agent-profiles/8', caches); + expect(caches.store.invalidate).toHaveBeenCalledWith('users'); + expect(caches.editorOptions.invalidate).toHaveBeenCalledWith(5, 'users'); + }); + + it('does nothing for unrelated endpoints', () => { + expect(applyReferenceInvalidation('/items/12', caches)).toBe(false); + expect(caches.store.invalidate).not.toHaveBeenCalled(); + expect(caches.editorOptions.invalidate).not.toHaveBeenCalled(); + expect(caches.editorOptions.invalidateFamily).not.toHaveBeenCalled(); + }); + + it('skips the workspace store while a global scope is active', () => { + caches.store.workspaceId = 'global'; + applyReferenceInvalidation('/milestones', caches); + expect(caches.store.invalidate).not.toHaveBeenCalled(); + expect(caches.editorOptions.invalidateFamily).toHaveBeenCalledWith('milestones'); + }); + + it('falls back to a full refresh for cross-cutting changes', () => { + applyReferenceInvalidation('/admin/object-translations/status/3/name/de', caches); + expect(caches.store.refresh).toHaveBeenCalled(); + expect(caches.editorOptions.reset).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/stores/toasts.spec.js b/frontend/src/lib/stores/toasts.spec.js new file mode 100644 index 0000000..f068be8 --- /dev/null +++ b/frontend/src/lib/stores/toasts.spec.js @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./i18n.svelte.js', () => ({ + t: (key) => `t:${key}`, + translateError: (error) => `translated:${error?.message ?? error}`, +})); + +import { removeToast, surfaceError, toasts } from './toasts.svelte.js'; + +afterEach(() => { + for (const toast of [...toasts.value]) removeToast(toast.id); + vi.restoreAllMocks(); +}); + +describe('surfaceError', () => { + it('logs the error with its context and adds an error toast', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('save failed'); + + surfaceError(error, { context: 'saveThing' }); + + expect(logged).toHaveBeenCalledWith('[saveThing]', error); + expect(toasts.value).toHaveLength(1); + expect(toasts.value[0]).toMatchObject({ + variant: 'error', + message: 'translated:save failed', + title: 't:toast.error', + }); + }); + + it('logs without a toast when silent', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError(new Error('background refresh failed'), { silent: true }); + + expect(logged).toHaveBeenCalledTimes(1); + expect(toasts.value).toHaveLength(0); + }); + + it('renders a thrown string directly', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError('plain failure'); + + expect(toasts.value[0]).toMatchObject({ message: 'plain failure' }); + }); + + it('lets the caller override the message and title', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError(new Error('raw'), { message: 'Could not save', title: 'Save failed' }); + + expect(toasts.value[0]).toMatchObject({ message: 'Could not save', title: 'Save failed' }); + }); + + it('adds a warning toast when asked', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError(new Error('degraded'), { level: 'warning' }); + + expect(toasts.value[0]).toMatchObject({ variant: 'warning' }); + }); + + it('ignores a caller-cancelled request', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError(new DOMException('The request was canceled', 'AbortError'), { + context: 'loadAfterClose', + }); + + expect(toasts.value).toHaveLength(0); + expect(logged).not.toHaveBeenCalled(); + }); + + it('ignores a REQUEST_CANCELED error that bypassed the API mapping', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + + surfaceError({ code: 'REQUEST_CANCELED', message: 'Request was canceled' }); + + expect(toasts.value).toHaveLength(0); + expect(logged).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/stores/workItemRefresh.test.js b/frontend/src/lib/stores/workItemRefresh.test.js new file mode 100644 index 0000000..c7b520b --- /dev/null +++ b/frontend/src/lib/stores/workItemRefresh.test.js @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + onWorkItemRefresh, + resetWorkItemRefresh, + workItemRefreshTouchesWorkspace, +} from './workItemRefresh.js'; + +function dispatch(detail) { + window.dispatchEvent(new CustomEvent('refresh-work-items', { detail })); +} + +afterEach(() => { + resetWorkItemRefresh(); +}); + +describe('workItemRefresh', () => { + it('notifies a subscriber once per event with the detail', async () => { + const seen = []; + const unsubscribe = onWorkItemRefresh((detail) => seen.push(detail)); + dispatch({ item: { id: 7, workspace_id: 3 }, itemId: 7, parentId: 2 }); + + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0].itemId).toBe(7); + expect(seen[0].item).toEqual({ id: 7, workspace_id: 3 }); + expect(seen[0].parentIds).toEqual([2]); + unsubscribe(); + }); + + it('coalesces a burst into one notification and merges the named ids', async () => { + const seen = []; + const unsubscribe = onWorkItemRefresh((detail) => seen.push(detail)); + dispatch({ itemId: 1, parentId: 10 }); + dispatch({ itemId: 2, parentId: 11 }); + dispatch({ itemId: 3, parentId: 10 }); + + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0].itemIds).toEqual([1, 2, 3]); + expect(seen[0].parentIds).toEqual([10, 11]); + unsubscribe(); + }); + + it('stops notifying after unsubscribe', async () => { + const seen = []; + const unsubscribe = onWorkItemRefresh((detail) => seen.push(detail)); + unsubscribe(); + vi.useFakeTimers(); + try { + dispatch({ itemId: 1 }); + + await vi.advanceTimersByTimeAsync(5); + expect(seen).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps notifying other subscribers when one throws', async () => { + const seen = []; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const unsubscribeA = onWorkItemRefresh(() => { + throw new Error('boom'); + }); + const unsubscribeB = onWorkItemRefresh((detail) => seen.push(detail)); + dispatch({ itemId: 9 }); + + await vi.waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0].itemId).toBe(9); + expect(errorSpy).toHaveBeenCalled(); + unsubscribeA(); + unsubscribeB(); + errorSpy.mockRestore(); + }); + + it('drops pending work on reset', async () => { + const seen = []; + onWorkItemRefresh((detail) => seen.push(detail)); + vi.useFakeTimers(); + try { + dispatch({ itemId: 1 }); + resetWorkItemRefresh(); + + await vi.advanceTimersByTimeAsync(5); + expect(seen).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('workItemRefreshTouchesWorkspace', () => { + it('matches the workspace named by the burst', () => { + expect(workItemRefreshTouchesWorkspace({ item: { workspace_id: 3 } }, 3)).toBe(true); + expect(workItemRefreshTouchesWorkspace({ item: { workspace_id: 3 } }, 4)).toBe(false); + }); + + it('treats a burst without workspace information as touching every workspace', () => { + expect(workItemRefreshTouchesWorkspace({ itemId: 9 }, 4)).toBe(true); + expect(workItemRefreshTouchesWorkspace({}, null)).toBe(true); + }); +}); diff --git a/frontend/src/lib/stores/workspaceGradient.svelte.test.js b/frontend/src/lib/stores/workspaceGradient.svelte.test.js index b6921cc..ba82bd4 100644 --- a/frontend/src/lib/stores/workspaceGradient.svelte.test.js +++ b/frontend/src/lib/stores/workspaceGradient.svelte.test.js @@ -82,4 +82,17 @@ describe('workspace gradient styles', () => { ); expect(styles.columnStyle(12)).toContain('backdrop-filter: blur(12px);'); }); + + it('uses a lighter glass hover on a custom background', () => { + workspaceGradientIndex.set(3); + applyToAllViews.set(true); + + expect(useGradientStyles().contextVars).toContain('--ctx-hover-bg: rgba(255, 255, 255, 0.85)'); + }); + + it('uses the neutral surface hover without a custom background', () => { + applyToAllViews.set(false); + + expect(useGradientStyles().contextVars).toContain('--ctx-hover-bg: var(--ds-surface-hovered)'); + }); }); diff --git a/frontend/src/lib/teams/ServiceHoursTab.test.js b/frontend/src/lib/teams/ServiceHoursTab.test.js index d2110ee..33c9e30 100644 --- a/frontend/src/lib/teams/ServiceHoursTab.test.js +++ b/frontend/src/lib/teams/ServiceHoursTab.test.js @@ -16,6 +16,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), successToast: vi.fn(), errorToast: vi.fn(), })); diff --git a/frontend/src/lib/utils/bulkActions.test.js b/frontend/src/lib/utils/bulkActions.test.js new file mode 100644 index 0000000..582a926 --- /dev/null +++ b/frontend/src/lib/utils/bulkActions.test.js @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + errorToast: vi.fn(), +})); + +vi.mock('../composables/useConfirm.js', () => ({ confirm: mocks.confirm })); +vi.mock('../stores/toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: mocks.errorToast })); +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); + +import { runBulkTransitionWithScreenRetry } from './bulkActions.js'; + +function screenRequired(blocked) { + const error = new Error('screen_required'); + error.status = 409; + error.code = 'screen_required'; + error.details = { items: blocked }; + return error; +} + +describe('runBulkTransitionWithScreenRetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the result without prompting when the batch succeeds', async () => { + const transition = vi.fn().mockResolvedValue({ transitioned_count: 2 }); + + const result = await runBulkTransitionWithScreenRetry(transition, [1, 2], 5); + + expect(result).toEqual({ transitioned_count: 2 }); + expect(transition).toHaveBeenCalledOnce(); + expect(mocks.confirm).not.toHaveBeenCalled(); + }); + + it('prompts and retries without the screen-blocked items', async () => { + mocks.confirm.mockResolvedValue(true); + const transition = vi + .fn() + .mockRejectedValueOnce(screenRequired([{ item_id: 2, key: 'WI-2' }])) + .mockResolvedValueOnce({ transitioned_count: 1 }); + + const result = await runBulkTransitionWithScreenRetry(transition, [1, 2, 3], 5); + + expect(mocks.confirm).toHaveBeenCalledOnce(); + expect(transition).toHaveBeenNthCalledWith(2, [1, 3], 5); + expect(result).toEqual({ transitioned_count: 1 }); + }); + + it('returns null and does not retry when the user declines', async () => { + mocks.confirm.mockResolvedValue(false); + const transition = vi.fn().mockRejectedValue(screenRequired([{ item_id: 2 }])); + + const result = await runBulkTransitionWithScreenRetry(transition, [1, 2], 5); + + expect(result).toBeNull(); + expect(transition).toHaveBeenCalledOnce(); + }); + + it('reports when every selected item is blocked', async () => { + const transition = vi + .fn() + .mockRejectedValue(screenRequired([{ item_id: 1 }, { item_id: 2 }])); + + const result = await runBulkTransitionWithScreenRetry(transition, [1, 2], 5); + + expect(result).toBeNull(); + expect(mocks.errorToast).toHaveBeenCalledOnce(); + expect(mocks.confirm).not.toHaveBeenCalled(); + }); + + it('rethrows an error that is not a screen refusal', async () => { + const failure = Object.assign(new Error('boom'), { status: 500 }); + const transition = vi.fn().mockRejectedValue(failure); + + await expect(runBulkTransitionWithScreenRetry(transition, [1], 5)).rejects.toBe(failure); + expect(mocks.confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/checkEntryAssets.test.js b/frontend/src/lib/utils/checkEntryAssets.test.js index ea938e7..1887f80 100644 --- a/frontend/src/lib/utils/checkEntryAssets.test.js +++ b/frontend/src/lib/utils/checkEntryAssets.test.js @@ -14,6 +14,8 @@ describe('findForbiddenEntryAssets', () => { it.each([ ['Excalidraw', './_app/excalidraw-abc.js'], + ['Mermaid', './_app/mermaid-abc.js'], + ['Cytoscape', './_app/cytoscape-abc.js'], ['React', './_app/react-dom-abc.js'], ['SvelteFlow', './_app/svelteflow-abc.js'], ['SvelteFlow', './_app/xyflow-abc.css'], diff --git a/frontend/src/lib/utils/concurrency.test.js b/frontend/src/lib/utils/concurrency.test.js new file mode 100644 index 0000000..cb07c08 --- /dev/null +++ b/frontend/src/lib/utils/concurrency.test.js @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import { runWithConcurrency, UPLOAD_CONCURRENCY } from "./concurrency.js"; + +describe("runWithConcurrency", () => { + test("settles every item in input order", async () => { + const results = await runWithConcurrency([1, 2, 3], async (n) => n * 2); + + expect(results.map((result) => result.item)).toEqual([1, 2, 3]); + expect(results.map((result) => result.ok)).toEqual([true, true, true]); + expect(results.map((result) => result.value)).toEqual([2, 4, 6]); + }); + + test("never exceeds the configured concurrency", async () => { + const items = Array.from({ length: 9 }, (_, i) => i); + let active = 0; + let peak = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const promise = runWithConcurrency( + items, + async () => { + active += 1; + peak = Math.max(peak, active); + if (active >= 3) release(); + await gate; + active -= 1; + return true; + }, + { concurrency: 3 }, + ); + + await promise; + expect(peak).toBe(3); + }); + + test("defaults to the shared upload concurrency", async () => { + const items = Array.from({ length: UPLOAD_CONCURRENCY + 4 }, (_, i) => i); + let active = 0; + let peak = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + await runWithConcurrency(items, async () => { + active += 1; + peak = Math.max(peak, active); + if (active >= UPLOAD_CONCURRENCY) release(); + await gate; + active -= 1; + }); + + expect(peak).toBe(UPLOAD_CONCURRENCY); + }); + + test("records a rejection without cancelling the remaining items", async () => { + const seen = []; + const results = await runWithConcurrency( + [1, 2, 3, 4, 5], + async (n) => { + seen.push(n); + if (n === 3) throw new Error("boom"); + return n; + }, + { concurrency: 2 }, + ); + + expect([...seen].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]); + expect(results.filter((result) => result.ok).map((result) => result.value)).toEqual([ + 1, 2, 4, 5, + ]); + const failed = results.find((result) => !result.ok); + expect(failed.item).toBe(3); + expect(failed.value).toBeNull(); + expect(failed.error.message).toBe("boom"); + }); + + test("returns an empty array when there is nothing to run", async () => { + await expect(runWithConcurrency([], async () => 1)).resolves.toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/createdItemToast.test.js b/frontend/src/lib/utils/createdItemToast.test.js new file mode 100644 index 0000000..2521b77 --- /dev/null +++ b/frontend/src/lib/utils/createdItemToast.test.js @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const toasts = []; + return { + toasts, + addToast: vi.fn((options) => { + const id = toasts.length + 1; + toasts.push({ id, options }); + return id; + }), + removeToast: vi.fn((id) => { + const index = toasts.findIndex((toast) => toast.id === id); + if (index === -1) return; + const [removed] = toasts.splice(index, 1); + removed.options.onDismiss?.(); + }), + navigate: vi.fn(), + openItemModal: vi.fn(), + isTypingInField: vi.fn(() => false), + }; +}); + +vi.mock('../router.js', () => ({ navigate: mocks.navigate })); +vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), + addToast: mocks.addToast, + removeToast: mocks.removeToast, +})); +vi.mock('../stores/itemModalStore.svelte.js', () => ({ + itemModalStore: { open: mocks.openItemModal }, +})); +vi.mock('./keyboardShortcuts.js', () => ({ + isTypingInField: mocks.isTypingInField, +})); + +import { showCreatedItemOutsideViewToast, showCreatedItemToast } from './createdItemToast.js'; + +const item = { + id: 42, + workspace_id: 7, + workspace_key: 'WI', + workspace_item_number: 42, + title: 'Fix login', +}; + +describe('createdItemToast', () => { + beforeEach(() => { + mocks.toasts.length = 0; + mocks.addToast.mockClear(); + mocks.removeToast.mockClear(); + mocks.navigate.mockClear(); + mocks.openItemModal.mockClear(); + mocks.isTypingInField.mockReset(); + mocks.isTypingInField.mockReturnValue(false); + }); + + afterEach(() => { + // Dismiss leftovers so their document keydown listeners never leak into + // the next test. + for (const toast of mocks.toasts) toast.options.onDismiss?.(); + mocks.toasts.length = 0; + vi.useRealTimers(); + }); + + it('shows the item key and opens the item modal when the created card is hidden by filters', () => { + const toastId = showCreatedItemOutsideViewToast(item, { + message: 'The card was created in Other, but is hidden by the collection filters.', + }); + + expect(mocks.addToast).toHaveBeenCalledTimes(1); + const options = mocks.addToast.mock.calls[0][0]; + expect(options).toMatchObject({ + title: 'WI-42 created', + message: 'The card was created in Other, but is hidden by the collection filters.', + variant: 'info', + clickable: true, + actionLabel: 'Open item', + keyboardHint: 'O', + }); + + options.onClick(); + + expect(mocks.openItemModal).toHaveBeenCalledWith(42, 7); + expect(mocks.removeToast).toHaveBeenCalledWith(toastId); + }); + + it('uses a caller-provided onOpen instead of the default item modal', () => { + const onOpen = vi.fn(); + showCreatedItemOutsideViewToast(item, { message: 'hidden', onOpen }); + + mocks.addToast.mock.calls[0][0].onClick(); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(mocks.openItemModal).not.toHaveBeenCalled(); + }); + + it('opens the hidden item with the "O" shortcut', () => { + showCreatedItemOutsideViewToast(item, { message: 'hidden' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' })); + + expect(mocks.openItemModal).toHaveBeenCalledWith(42, 7); + }); + + it('ignores the "O" shortcut while typing in a field', () => { + mocks.isTypingInField.mockReturnValue(true); + showCreatedItemOutsideViewToast(item, { message: 'hidden' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' })); + + expect(mocks.openItemModal).not.toHaveBeenCalled(); + }); + + it('falls back to a WORK- key when the workspace key is unavailable', () => { + showCreatedItemOutsideViewToast( + { id: 9, workspace_id: 1, title: 'Untyped' }, + { message: 'hidden' } + ); + + expect(mocks.addToast.mock.calls[0][0].title).toBe('WORK-9 created'); + }); + + it('does not show a toast for an item without an id', () => { + expect(showCreatedItemOutsideViewToast(null, { message: 'hidden' })).toBeNull(); + expect(showCreatedItemOutsideViewToast({}, { message: 'hidden' })).toBeNull(); + + expect(mocks.addToast).not.toHaveBeenCalled(); + }); + + it('keeps navigating to the item page for the visible-item success toast', () => { + showCreatedItemToast(item); + + const options = mocks.addToast.mock.calls[0][0]; + expect(options).toMatchObject({ + title: 'WI-42 created', + variant: 'success', + clickable: true, + actionLabel: 'Open item', + }); + + options.onClick(); + + expect(mocks.navigate).toHaveBeenCalledWith('/workspaces/7/items/42'); + }); +}); diff --git a/frontend/src/lib/utils/customFieldValues.test.js b/frontend/src/lib/utils/customFieldValues.test.js new file mode 100644 index 0000000..55cc001 --- /dev/null +++ b/frontend/src/lib/utils/customFieldValues.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { fieldValue, toIdKeyedCustomFieldValues } from './customFieldValues.js'; + +const field = { id: 7, name: 'Account tier' }; + +describe('fieldValue', () => { + it('prefers the numeric id key over the legacy name mirror', () => { + expect(fieldValue({ 7: 'gold', 'Account tier': 'stale' }, field)).toBe('gold'); + }); + + it('falls back to the legacy name key', () => { + expect(fieldValue({ 'Account tier': 'gold' }, field)).toBe('gold'); + }); + + it('treats an explicit empty id key as cleared instead of falling back', () => { + expect(fieldValue({ 7: null, 'Account tier': 'stale' }, field)).toBeNull(); + expect(fieldValue({ 7: '', 'Account tier': 'stale' }, field)).toBe(''); + }); +}); + +describe('toIdKeyedCustomFieldValues', () => { + it('re-keys name values to ids and drops name keys', () => { + expect( + toIdKeyedCustomFieldValues({ 'Account tier': 'gold', orphan: 1 }, [field]) + ).toEqual({ 7: 'gold' }); + }); + + it('copies an ambiguous name value onto every matching field', () => { + const duplicates = [ + { id: 8, name: 'Priority' }, + { id: 9, name: 'Priority' }, + ]; + expect(toIdKeyedCustomFieldValues({ Priority: 2 }, duplicates)).toEqual({ 8: 2, 9: 2 }); + }); +}); diff --git a/frontend/src/lib/utils/hierarchy.test.js b/frontend/src/lib/utils/hierarchy.test.js index 2443b0b..0e85db7 100644 --- a/frontend/src/lib/utils/hierarchy.test.js +++ b/frontend/src/lib/utils/hierarchy.test.js @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { canItemTypeBeChildOf, childItemTypesForParent, GENERIC_SUBTASK_HIERARCHY_LEVEL, isGenericSubtaskType, sortItemTypesByHierarchy } from "./hierarchy.js"; +import { canItemTypeBeChildOf, childItemTypesForLevel, childItemTypesForParent, GENERIC_SUBTASK_HIERARCHY_LEVEL, isGenericSubtaskType, sortItemTypesByHierarchy } from "./hierarchy.js"; const epic = { id: 1, name: "Epic", hierarchy_level: 0, sort_order: 1 }; const story = { id: 2, name: "Story", hierarchy_level: 1, sort_order: 2 }; @@ -51,3 +51,30 @@ describe("generic subtask hierarchy", () => { ).toEqual([epic, story, task, genericSubtask]); }); }); + +describe("childItemTypesForLevel", () => { + test("returns the next level plus generic subtasks for a known level", () => { + expect( + childItemTypesForLevel([epic, story, task, genericSubtask], 0), + ).toEqual([story, genericSubtask]); + }); + + test("treats a numeric string level like a number", () => { + expect(childItemTypesForLevel([epic, story, task], "1")).toEqual([task]); + }); + + test("returns nothing when the parent level is unknown", () => { + expect(childItemTypesForLevel([epic, story], null)).toEqual([]); + expect(childItemTypesForLevel([epic, story], undefined)).toEqual([]); + expect(childItemTypesForLevel([epic, story], "nope")).toEqual([]); + }); + + test("treats the generic subtask level as terminal", () => { + expect( + childItemTypesForLevel( + [epic, story, task, genericSubtask], + GENERIC_SUBTASK_HIERARCHY_LEVEL, + ), + ).toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/workItemListColumns.test.js b/frontend/src/lib/utils/workItemListColumns.test.js index 079523c..1dc1dc0 100644 --- a/frontend/src/lib/utils/workItemListColumns.test.js +++ b/frontend/src/lib/utils/workItemListColumns.test.js @@ -69,4 +69,16 @@ describe('listGridMinWidth', () => { // + actions 4 + 5 gaps = 52rem expect(listGridMinWidth(columns)).toBe('52rem'); }); + + test('accepts tree-specific key and title widths', () => { + const columns = [ + { field_identifier: 'key', width: 2 }, + { field_identifier: 'title', width: 4 }, + ]; + const template = listGridTemplateColumns(columns, { keyWidth: 10, titleMinWidth: 24 }); + expect(template.split(' ')[0]).toBe('10rem'); + expect(template).toContain('minmax(24rem, 4fr)'); + // key 10 + title 24 + actions 4 + 2 gaps = 40rem + expect(listGridMinWidth(columns, { keyWidth: 10, titleMinWidth: 24 })).toBe('40rem'); + }); }); diff --git a/frontend/src/lib/widgets/WidgetWrapper.test.js b/frontend/src/lib/widgets/WidgetWrapper.test.js index 947dce4..fbc8848 100644 --- a/frontend/src/lib/widgets/WidgetWrapper.test.js +++ b/frontend/src/lib/widgets/WidgetWrapper.test.js @@ -7,7 +7,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ t: (key, params = {}) => (key === 'widgets.resizeColumnsValue' ? '{count} of {total} columns' : key).replace( /\{(\w+)\}/g, - (_, name) => (params[name] ?? `{${name}}`) + (_, name) => params[name] ?? `{${name}}` ), })); @@ -68,7 +68,7 @@ describe('WidgetWrapper workspace resizing', () => { expect(screen.getByTestId('widget-resize-grip')).toBeInTheDocument(); }); - it('keeps the resize handle available outside edit mode but out of the tab order', () => { + it('does not render the resize handle outside edit mode', () => { render(WidgetWrapper, { props: { gridColumns: 3, @@ -80,9 +80,7 @@ describe('WidgetWrapper workspace resizing', () => { }, }); - const handle = screen.getByTestId('widget-resize-handle'); - expect(handle).toHaveAttribute('tabindex', '-1'); - expect(handle).toHaveAttribute('aria-valuetext', '1 of 3 columns'); + expect(screen.queryByTestId('widget-resize-handle')).not.toBeInTheDocument(); }); it('makes the resize handle keyboard-focusable in edit mode', () => { @@ -121,11 +119,17 @@ describe('WidgetWrapper workspace resizing', () => { }); describe('WidgetWrapper width menu trigger', () => { - it('renders a single kebab icon without a built-in chevron', () => { - render(WidgetWrapper, { props: {} }); + it('renders a single kebab icon without a built-in chevron in edit mode', () => { + render(WidgetWrapper, { props: { isEditing: true } }); const trigger = screen.getByTestId('widget-width-menu'); expect(trigger.querySelectorAll('svg')).toHaveLength(1); expect(trigger.querySelector('svg')).toHaveClass('lucide-ellipsis'); }); + + it('does not render the widget menu outside edit mode', () => { + render(WidgetWrapper, { props: { isEditing: false } }); + + expect(screen.queryByTestId('widget-width-menu')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/workspaces/Customers.test.js b/frontend/src/lib/workspaces/Customers.test.js index f3fb349..be7be71 100644 --- a/frontend/src/lib/workspaces/Customers.test.js +++ b/frontend/src/lib/workspaces/Customers.test.js @@ -47,6 +47,7 @@ vi.mock('../stores/permissions.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: mocks.errorToast, successToast: vi.fn(), warningToast: vi.fn(), diff --git a/frontend/src/lib/workspaces/NavigationConfigPage.test.js b/frontend/src/lib/workspaces/NavigationConfigPage.test.js index 6b7b32c..7bb4d13 100644 --- a/frontend/src/lib/workspaces/NavigationConfigPage.test.js +++ b/frontend/src/lib/workspaces/NavigationConfigPage.test.js @@ -4,8 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ getBoardConfiguration: vi.fn(), - updateBoardConfiguration: vi.fn(), - createBoardConfiguration: vi.fn(), + patchBoardConfiguration: vi.fn(), getCollection: vi.fn(), getWorkspace: vi.fn(), })); @@ -13,8 +12,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../api/collections.js', () => ({ collections: { getBoardConfiguration: mocks.getBoardConfiguration, - updateBoardConfiguration: mocks.updateBoardConfiguration, - createBoardConfiguration: mocks.createBoardConfiguration, + patchBoardConfiguration: mocks.patchBoardConfiguration, get: mocks.getCollection, }, collectionCategories: { getAll: vi.fn().mockResolvedValue([]) }, @@ -39,6 +37,7 @@ vi.mock('../stores/i18n.svelte.js', () => ({ })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), })); @@ -71,11 +70,25 @@ const WORKSPACE_ONLY_IDS = ['agents', 'iterations', 'milestones', 'analytics', ' // A stored collection config whose effective set inherits workspace-only // tools ids from the workspace row, as the backend merges them into every -// collection-scope read. +// collection-scope read. The columns carry the server-managed fields the +// full-replace endpoint rejects; the merge patch must never send them. function collectionConfigResponse() { return { id: 42, - columns: [], + columns: [ + { + id: 9, + board_configuration_id: 42, + name: 'In progress', + display_order: 0, + wip_limit: null, + color: '#123456', + status_ids: [2], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + card_fields: [{ field_identifier: 'title', field_type: 'system', display_order: 0, width: 4 }], view_settings: { enabled_views: [...COLLECTION_VIEW_IDS, 'queue', 'milestones'], }, @@ -88,7 +101,7 @@ function boardConfigRow(id) { } beforeEach(() => { - mocks.updateBoardConfiguration.mockImplementation((_collectionId, _configId, payload) => + mocks.patchBoardConfiguration.mockImplementation((_collectionId, payload) => Promise.resolve({ ...collectionConfigResponse(), ...payload })); }); @@ -106,18 +119,20 @@ describe('NavigationConfigPage collection scope', () => { await waitFor(() => expect(boardConfigRow('board')).toBeInTheDocument()); expect(boardConfigRow('overview')).toBeInTheDocument(); - expect(boardConfigRow('test-cases')).toBeInTheDocument(); - // Workspace-only tools cannot be toggled at collection scope, so the - // screen must not offer them at all — and neither the workspace-scoped - // queue view nor the fixed workspace affordances. + // A collection's nav has no test entries, so its config page must not + // offer them either. Workspace-only tools cannot be toggled at collection + // scope, so the screen must not offer them at all — and neither the + // workspace-scoped queue view nor the fixed workspace affordances. + expect(screen.queryByTestId('nav-config-row-test-cases')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-config-section-tests')).not.toBeInTheDocument(); expect(screen.queryByTestId('nav-config-section-tools')).not.toBeInTheDocument(); for (const id of [...WORKSPACE_ONLY_IDS, QUEUE_ID]) { expect(screen.queryByTestId(`nav-config-row-${id}`)).not.toBeInTheDocument(); } }); - test('toggling a view saves only collection view ids despite inherited tools ids', async () => { + test('toggling a view patches only view_settings, never the board columns', async () => { mocks.getBoardConfiguration.mockResolvedValue(collectionConfigResponse()); mocks.getCollection.mockResolvedValue({ id: 7, name: 'Alpha' }); @@ -126,8 +141,12 @@ describe('NavigationConfigPage collection scope', () => { fireEvent.click(screen.getByTestId('nav-config-toggle-list')); - await waitFor(() => expect(mocks.updateBoardConfiguration).toHaveBeenCalledTimes(1)); - const [, , payload] = mocks.updateBoardConfiguration.mock.calls[0]; + await waitFor(() => expect(mocks.patchBoardConfiguration).toHaveBeenCalledTimes(1)); + const [, payload] = mocks.patchBoardConfiguration.mock.calls[0]; + // The page owns view_settings only. Sending the fetched columns (with + // board_configuration_id/created_at/updated_at) is what the strict v2 + // decoder rejected as "not valid JSON". + expect(Object.keys(payload)).toEqual(['view_settings']); expect(payload.view_settings.enabled_views).toEqual( COLLECTION_VIEW_IDS.filter((id) => id !== 'list'), ); @@ -146,9 +165,9 @@ describe('NavigationConfigPage collection scope', () => { test('a failed save does not discard a later toggle', async () => { mocks.getBoardConfiguration.mockResolvedValue(collectionConfigResponse()); mocks.getCollection.mockResolvedValue({ id: 7, name: 'Alpha' }); - mocks.updateBoardConfiguration + mocks.patchBoardConfiguration .mockRejectedValueOnce(new Error('offline')) - .mockImplementation((_collectionId, _configId, payload) => + .mockImplementation((_collectionId, payload) => Promise.resolve({ ...collectionConfigResponse(), ...payload }) ); @@ -159,14 +178,14 @@ describe('NavigationConfigPage collection scope', () => { fireEvent.click(screen.getByTestId('nav-config-toggle-list')); fireEvent.click(screen.getByTestId('nav-config-toggle-tree')); - await waitFor(() => expect(mocks.updateBoardConfiguration).toHaveBeenCalledTimes(2)); - const [, , secondPayload] = mocks.updateBoardConfiguration.mock.calls[1]; + await waitFor(() => expect(mocks.patchBoardConfiguration).toHaveBeenCalledTimes(2)); + const [, secondPayload] = mocks.patchBoardConfiguration.mock.calls[1]; // The later toggle must survive the earlier failure and be persisted. expect(secondPayload.view_settings.enabled_views).not.toContain('list'); expect(secondPayload.view_settings.enabled_views).not.toContain('tree'); }); - test('resetting sends a null enabled_views override', async () => { + test('resetting patches only a null enabled_views override', async () => { mocks.getBoardConfiguration.mockResolvedValue(collectionConfigResponse()); mocks.getCollection.mockResolvedValue({ id: 7, name: 'Alpha' }); @@ -175,8 +194,11 @@ describe('NavigationConfigPage collection scope', () => { fireEvent.click(screen.getByTestId('nav-config-reset')); - await waitFor(() => expect(mocks.updateBoardConfiguration).toHaveBeenCalledTimes(1)); - const [, , payload] = mocks.updateBoardConfiguration.mock.calls[0]; + await waitFor(() => expect(mocks.patchBoardConfiguration).toHaveBeenCalledTimes(1)); + const [, payload] = mocks.patchBoardConfiguration.mock.calls[0]; + // A full-replace payload here would zero the collection's columns, card + // fields, and roadmap config. + expect(Object.keys(payload)).toEqual(['view_settings']); expect(payload.view_settings.enabled_views).toBeNull(); }); diff --git a/frontend/src/lib/workspaces/OrganisationDetail.test.js b/frontend/src/lib/workspaces/OrganisationDetail.test.js index b501cb0..ac25004 100644 --- a/frontend/src/lib/workspaces/OrganisationDetail.test.js +++ b/frontend/src/lib/workspaces/OrganisationDetail.test.js @@ -5,7 +5,6 @@ const mocks = vi.hoisted(() => ({ getTickets: vi.fn(), setRequestSharing: vi.fn(), contactRolesGetAll: vi.fn(), - listDocuments: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); @@ -18,19 +17,12 @@ vi.mock('../api/portal.js', () => ({ contactRoles: { getAll: mocks.contactRolesGetAll }, })); -vi.mock('../api/logbook.js', () => ({ - logbook: { listDocumentsByOrganisation: mocks.listDocuments }, -})); - -vi.mock('../stores/capabilities.svelte.js', () => ({ - capabilitiesStore: { logbookAvailable: false }, -})); - vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key, })); vi.mock('../stores/toasts.svelte.js', () => ({ + surfaceError: vi.fn(), errorToast: mocks.errorToast, successToast: mocks.successToast, })); @@ -45,7 +37,6 @@ vi.mock('../utils/dateFormatter.js', () => ({ import OrganisationDetail from './OrganisationDetail.svelte'; import { contactRoles, customerOrganisations } from '../api/portal.js'; -import { logbook } from '../api/logbook.js'; const orgA = { id: 1, name: 'Org A', settings: { request_sharing: 'disabled' } }; diff --git a/frontend/src/lib/workspaces/WorkspaceCannedResponses.test.js b/frontend/src/lib/workspaces/WorkspaceCannedResponses.test.js index 3da919d..59ed697 100644 --- a/frontend/src/lib/workspaces/WorkspaceCannedResponses.test.js +++ b/frontend/src/lib/workspaces/WorkspaceCannedResponses.test.js @@ -19,6 +19,7 @@ vi.mock("../stores/i18n.svelte.js", () => ({ })); vi.mock("../stores/toasts.svelte.js", () => ({ + surfaceError: vi.fn(), errorToast: vi.fn(), successToast: vi.fn(), })); diff --git a/frontend/src/lib/workspaces/WorkspaceWelcome.test.js b/frontend/src/lib/workspaces/WorkspaceWelcome.test.js new file mode 100644 index 0000000..4dc58cd --- /dev/null +++ b/frontend/src/lib/workspaces/WorkspaceWelcome.test.js @@ -0,0 +1,148 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// WI-1932: the workspace dashboard is the second consumer of +// useDashboardCustomization. These tests cover the adapters it injects, which +// the composable's own unit tests cannot see. + +const mocks = vi.hoisted(() => ({ + loadWorkspaceGradient: vi.fn(), +})); + +vi.mock('../stores/workspaceGradient.svelte.js', () => ({ + useGradientStyles: () => ({ glassTextStyle: '', glassSubtleTextStyle: '' }), + loadWorkspaceGradient: mocks.loadWorkspaceGradient, + hydrateWorkspaceGradientLayout: vi.fn(), + workspaceGradientIndex: writable(null), + applyToAllViews: writable(false), + workspaceBackgroundImageUrl: writable(''), +})); + +vi.mock('../api/workspaces.js', () => ({ + workspaces: { + get: vi.fn().mockResolvedValue({ id: 7, name: 'Workspace', key: 'WS' }), + getStats: vi.fn().mockResolvedValue({ + totalCollections: 0, + itemsByStatusCategory: {}, + totalItems: 0, + }), + updateHomepageLayout: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('../api/workflows.js', () => ({ + statusCategories: { getAll: vi.fn().mockResolvedValue([]) }, + statuses: { getAll: vi.fn().mockResolvedValue([]) }, +})); + +vi.mock('../api/items.js', () => ({ + items: { getAll: vi.fn().mockResolvedValue({ items: [], total: 0 }) }, +})); + +vi.mock('../features/collections/collectionService.js', () => ({ + getCollection: vi.fn().mockResolvedValue(null), +})); + +vi.mock('./WorkspaceCustomizationSidebar.svelte', () => ({ + default: function WorkspaceCustomizationSidebar() {}, +})); + +vi.mock('../widgets/WidgetWrapper.svelte', () => ({ + default: function WidgetWrapper() {}, +})); + +const { default: WorkspaceWelcome } = await import('./WorkspaceWelcome.svelte'); +const { workspaces } = await import('../api/workspaces.js'); + +const layout = () => ({ + sections: [ + { id: 's1', title: 'Alpha', subtitle: '', display_order: 0, widget_ids: [] }, + { id: 's2', title: 'Beta', subtitle: '', display_order: 1, widget_ids: [] }, + ], + widgets: [], + gradient: null, + applyToAllViews: false, + backgroundImageUrl: '', +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.loadWorkspaceGradient.mockResolvedValue(layout()); +}); + +// The component debounces layout saves by 1s, and runed rejects a pending +// debounce with "Cancelled" when the component unmounts. Every test that leaves +// a pending save must therefore wait for it before finishing. +async function waitForPersistedSave() { + await waitFor(() => expect(workspaces.updateHomepageLayout).toHaveBeenCalled(), { + timeout: 3000, + }); + return workspaces.updateHomepageLayout.mock.calls.at(-1)[1]; +} + +describe('WorkspaceWelcome dashboard section editing', () => { + it('renames a section through the shared state machine and persists it', async () => { + render(WorkspaceWelcome, { props: { workspaceId: 7 } }); + + await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + + await fireEvent.click(screen.getAllByTestId('workspace-section-edit')[0]); + + const titleInput = await screen.findByTestId('workspace-section-title-input'); + await fireEvent.input(titleInput, { target: { value: 'Renamed' } }); + await fireEvent.keyDown(titleInput, { key: 'Enter' }); + + await waitFor(() => expect(screen.getByText('Renamed')).toBeInTheDocument()); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + + // The injected updateSection adapter must also persist the layout, not just + // update the local sections array. + const savedLayout = await waitForPersistedSave(); + expect(savedLayout.sections.find((section) => section.id === 's1').title).toBe('Renamed'); + }); + + it('keeps a new section when the title is set and discards it when cleared', async () => { + render(WorkspaceWelcome, { props: { workspaceId: 7 } }); + + await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + + // A new section starts with a non-empty default title, so clearing it is + // what makes leaving edit mode discard the section. + await fireEvent.click(screen.getByTestId('workspace-add-section')); + const clearedInput = await screen.findByTestId('workspace-section-title-input'); + await fireEvent.input(clearedInput, { target: { value: ' ' } }); + expect(screen.getAllByTestId('workspace-section')).toHaveLength(3); + + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + await waitFor(() => expect(screen.getAllByTestId('workspace-section')).toHaveLength(2)); + + // A typed title keeps the section instead. + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + await fireEvent.click(screen.getByTestId('workspace-add-section')); + const titledInput = await screen.findByTestId('workspace-section-title-input'); + await fireEvent.input(titledInput, { target: { value: 'Kept' } }); + + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + await waitFor(() => expect(screen.getByText('Kept')).toBeInTheDocument()); + expect(screen.getAllByTestId('workspace-section')).toHaveLength(3); + + const savedLayout = await waitForPersistedSave(); + expect(savedLayout.sections.some((section) => section.title === 'Kept')).toBe(true); + }); + + it('discards a new section on Escape', async () => { + render(WorkspaceWelcome, { props: { workspaceId: 7 } }); + + await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + await fireEvent.click(screen.getByTestId('workspace-dashboard-edit-toggle')); + await fireEvent.click(screen.getByTestId('workspace-add-section')); + + const titleInput = await screen.findByTestId('workspace-section-title-input'); + await fireEvent.keyDown(titleInput, { key: 'Escape' }); + + await waitFor(() => expect(screen.getAllByTestId('workspace-section')).toHaveLength(2)); + }); +}); diff --git a/frontend/src/lib/workspaces/Workspaces.test.js b/frontend/src/lib/workspaces/Workspaces.test.js index 51a713e..893e2a7 100644 --- a/frontend/src/lib/workspaces/Workspaces.test.js +++ b/frontend/src/lib/workspaces/Workspaces.test.js @@ -47,7 +47,7 @@ vi.mock('../stores/permissions.svelte.js', async () => { vi.mock('../router.js', () => ({ navigate: vi.fn() })); vi.mock('../composables/useConfirm.js', () => ({ confirm: vi.fn() })); -vi.mock('../stores/toasts.svelte.js', () => ({ errorToast: vi.fn() })); +vi.mock('../stores/toasts.svelte.js', () => ({ surfaceError: vi.fn(), errorToast: vi.fn() })); vi.mock('../utils/keyboardShortcuts.js', () => ({ toHotkeyString: () => '', getShortcutDisplay: () => '', diff --git a/internal/aitooladapter/adapter_test.go b/internal/aitooladapter/adapter_test.go index 9db41bb..ad61f1f 100644 --- a/internal/aitooladapter/adapter_test.go +++ b/internal/aitooladapter/adapter_test.go @@ -2,6 +2,7 @@ package aitooladapter import ( "context" + "encoding/json" "slices" "testing" @@ -32,6 +33,28 @@ func TestEntriesForStandardUsesMandatoryPresetAndFiltersUnsafeAccess(t *testing. } } +func TestBuildToolsEmitsObjectPropertiesForEveryTool(t *testing.T) { + // llama.cpp (and therefore LM Studio) rejects a function whose parameters + // schema has no "properties" member, which is how a no-arg Args struct + // marshals unless the registry normalizes it. + for _, tool := range BuildTools(aitools.Default.All()) { + t.Run(tool.Function.Name, func(t *testing.T) { + var schema map[string]json.RawMessage + if err := json.Unmarshal(tool.Function.Parameters, &schema); err != nil { + t.Fatalf("parameters is not an object: %v", err) + } + properties, ok := schema["properties"] + if !ok { + t.Fatalf("parameters has no properties member: %s", tool.Function.Parameters) + } + var props map[string]json.RawMessage + if err := json.Unmarshal(properties, &props); err != nil { + t.Fatalf("properties is not an object: %v", err) + } + }) + } +} + func TestExecutorRejectsToolsOutsideItsAdmissionSnapshot(t *testing.T) { executor := NewExecutor(&aitools.Env{}, nil) got, err := executor.Execute(context.Background(), "get_item", `{"item_id":1}`) diff --git a/internal/cacheutil/typed_test.go b/internal/cacheutil/typed_test.go new file mode 100644 index 0000000..3c345ca --- /dev/null +++ b/internal/cacheutil/typed_test.go @@ -0,0 +1,142 @@ +package cacheutil + +import ( + "testing" + "time" +) + +type typedCachePayload struct { + values map[string]bool +} + +func TestTypedCacheGetSetDeleteReset(t *testing.T) { + cache := NewTyped[*typedCachePayload]("typed_basic", TypedCacheOptions{TTL: time.Hour, Shards: 4}) + t.Cleanup(func() { _ = cache.Close() }) + + if _, ok := cache.Get(1); ok { + t.Fatal("Get on empty cache returned a value") + } + value := &typedCachePayload{values: map[string]bool{"a": true}} + cache.Set(1, value) + + got, ok := cache.Get(1) + if !ok || got != value { + t.Fatalf("Get = (%p, %t), want the stored pointer %p", got, ok, value) + } + if cache.Len() != 1 { + t.Fatalf("Len = %d, want 1", cache.Len()) + } + + if !cache.Delete(1) { + t.Fatal("Delete reported missing an existing key") + } + if cache.Delete(1) { + t.Fatal("Delete reported an already-deleted key") + } + if cache.Len() != 0 { + t.Fatalf("Len after delete = %d, want 0", cache.Len()) + } + + cache.Set(2, value) + cache.Set(3, value) + cache.Reset() + if cache.Len() != 0 { + t.Fatalf("Len after reset = %d, want 0", cache.Len()) + } + if _, ok := cache.Get(2); ok { + t.Fatal("Get returned a value after reset") + } +} + +func TestTypedCacheExpiredEntryIsAMiss(t *testing.T) { + // A negative TTL stores an already-expired entry, so the read path can be + // exercised without waiting on a clock. + cache := NewTyped[*typedCachePayload]("typed_expiry", TypedCacheOptions{TTL: -time.Second, Shards: 1}) + t.Cleanup(func() { _ = cache.Close() }) + + cache.Set(1, &typedCachePayload{}) + if _, ok := cache.Get(1); ok { + t.Fatal("Get returned an expired entry") + } + if cache.Len() != 0 { + t.Fatalf("Len = %d, want expired entry reclaimed on read", cache.Len()) + } +} + +func TestTypedCacheSweepReclaimsExpiredEntries(t *testing.T) { + cache := NewTyped[*typedCachePayload]("typed_sweep", TypedCacheOptions{ + TTL: time.Minute, + Shards: 2, + SweepInterval: time.Hour, + }) + t.Cleanup(func() { _ = cache.Close() }) + + cache.Set(1, &typedCachePayload{}) + cache.Set(2, &typedCachePayload{}) + cache.sweep(time.Now().Add(2 * time.Minute)) + + if cache.Len() != 0 { + t.Fatalf("Len after sweep = %d, want 0", cache.Len()) + } +} + +func TestTypedCacheBoundsEntriesAndEvictsSoonestExpiring(t *testing.T) { + cache := NewTyped[*typedCachePayload]("typed_bounded", TypedCacheOptions{ + TTL: time.Hour, + MaxEntries: 2, + Shards: 1, + }) + t.Cleanup(func() { _ = cache.Close() }) + + first := &typedCachePayload{} + cache.Set(1, first) + cache.Set(2, &typedCachePayload{}) + cache.Set(3, &typedCachePayload{}) + + if cache.Len() != 2 { + t.Fatalf("Len = %d, want the 2-entry bound", cache.Len()) + } + if _, ok := cache.Get(1); ok { + t.Fatal("soonest-expiring entry survived the count bound") + } + if _, ok := cache.Get(3); !ok { + t.Fatal("newest entry was evicted instead of the soonest-expiring entry") + } + + snapshot := cache.cacheSnapshot("typed_bounded") + if snapshot.NoSpaceEvictions != 1 { + t.Fatalf("NoSpaceEvictions = %d, want 1", snapshot.NoSpaceEvictions) + } +} + +func TestTypedCacheSnapshotReportsTrafficAndEntries(t *testing.T) { + cache := NewTyped[*typedCachePayload]("typed_snapshot", TypedCacheOptions{TTL: time.Hour, Shards: 2}) + t.Cleanup(func() { _ = cache.Close() }) + + cache.Set(7, &typedCachePayload{}) + if _, ok := cache.Get(7); !ok { + t.Fatal("Get missed a stored entry") + } + if _, ok := cache.Get(8); ok { + t.Fatal("Get hit a missing entry") + } + + snapshot := cache.cacheSnapshot("typed_snapshot") + if snapshot.Name != "typed_snapshot" || snapshot.Entries != 1 || snapshot.Hits != 1 || snapshot.Misses != 1 { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } + if snapshot.AllocatedCapacityBytes != 0 || snapshot.MaximumCapacityBytes != 0 { + t.Fatalf("typed cache should not report byte capacity: %+v", snapshot) + } + + // The cache must be discoverable through the shared diagnostics registry. + found := false + for _, registered := range Snapshots() { + if registered.Name == "typed_snapshot" { + found = registered.Entries == 1 && registered.Hits == 1 && registered.Misses == 1 + } + } + if !found { + t.Fatal("typed cache was not registered for process diagnostics") + } +} diff --git a/internal/config/h2c_test.go b/internal/config/h2c_test.go new file mode 100644 index 0000000..4310ccb --- /dev/null +++ b/internal/config/h2c_test.go @@ -0,0 +1,46 @@ +//go:build test + +package config + +import ( + "embed" + "flag" + "os" + "testing" +) + +func TestLoadH2CEnabled(t *testing.T) { + tests := []struct { + name string + args []string + env string + want bool + }{ + {name: "off by default", want: false}, + {name: "flag enables h2c", args: []string{"--h2c"}, want: true}, + {name: "environment enables h2c", env: "1", want: true}, + {name: "environment true enables h2c", env: "true", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousCommandLine := flag.CommandLine + previousArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = previousCommandLine + os.Args = previousArgs + }) + + flag.CommandLine = flag.NewFlagSet("windshift-test", flag.ContinueOnError) + os.Args = append([]string{"windshift-test"}, tt.args...) + t.Setenv("SSO_SECRET", "test-session-secret") + t.Setenv("WINDSHIFT_MEMORY_LIMIT_MB", "") + t.Setenv("H2C_ENABLED", tt.env) + + cfg := Load(embed.FS{}, make(chan os.Signal, 1)) + if cfg.H2CEnabled != tt.want { + t.Fatalf("H2CEnabled = %v, want %v", cfg.H2CEnabled, tt.want) + } + }) + } +} diff --git a/internal/config/jira_import_concurrency_test.go b/internal/config/jira_import_concurrency_test.go new file mode 100644 index 0000000..af2465c --- /dev/null +++ b/internal/config/jira_import_concurrency_test.go @@ -0,0 +1,46 @@ +//go:build test + +package config + +import ( + "embed" + "flag" + "os" + "testing" +) + +// TestLoadJiraImportConcurrency is the WI-1982 configuration contract: the +// import pool uses the built-in default unless an operator overrides it. +func TestLoadJiraImportConcurrency(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "unset uses the built-in default", want: 0}, + {name: "explicit value is read from the environment", env: "4", want: 4}, + {name: "unparseable value keeps the default", env: "many", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousCommandLine := flag.CommandLine + previousArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = previousCommandLine + os.Args = previousArgs + }) + + flag.CommandLine = flag.NewFlagSet("windshift-test", flag.ContinueOnError) + os.Args = []string{"windshift-test"} + t.Setenv("SSO_SECRET", "test-session-secret") + t.Setenv("WINDSHIFT_MEMORY_LIMIT_MB", "") + t.Setenv("JIRA_IMPORT_CONCURRENCY", tt.env) + + cfg := Load(embed.FS{}, make(chan os.Signal, 1)) + if cfg.Jira.ImportConcurrency != tt.want { + t.Fatalf("Jira.ImportConcurrency = %d, want %d", cfg.Jira.ImportConcurrency, tt.want) + } + }) + } +} diff --git a/internal/config/jira_import_timeout_test.go b/internal/config/jira_import_timeout_test.go new file mode 100644 index 0000000..7549882 --- /dev/null +++ b/internal/config/jira_import_timeout_test.go @@ -0,0 +1,48 @@ +//go:build test + +package config + +import ( + "embed" + "flag" + "os" + "testing" + "time" +) + +// TestLoadJiraImportMaxDuration is the WI-1981 configuration contract: the +// overall Jira import limit is off unless an operator sets it. +func TestLoadJiraImportMaxDuration(t *testing.T) { + tests := []struct { + name string + env string + want time.Duration + }{ + {name: "unset disables the overall limit", want: 0}, + {name: "explicit zero disables the overall limit", env: "0s", want: 0}, + {name: "duration is read from the environment", env: "90m", want: 90 * time.Minute}, + {name: "unparseable value keeps the default", env: "not-a-duration", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousCommandLine := flag.CommandLine + previousArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = previousCommandLine + os.Args = previousArgs + }) + + flag.CommandLine = flag.NewFlagSet("windshift-test", flag.ContinueOnError) + os.Args = []string{"windshift-test"} + t.Setenv("SSO_SECRET", "test-session-secret") + t.Setenv("WINDSHIFT_MEMORY_LIMIT_MB", "") + t.Setenv("JIRA_IMPORT_MAX_DURATION", tt.env) + + cfg := Load(embed.FS{}, make(chan os.Signal, 1)) + if cfg.Jira.ImportMaxDuration != tt.want { + t.Fatalf("Jira.ImportMaxDuration = %v, want %v", cfg.Jira.ImportMaxDuration, tt.want) + } + }) + } +} diff --git a/internal/config/jira_import_watchers_test.go b/internal/config/jira_import_watchers_test.go new file mode 100644 index 0000000..5dc5c78 --- /dev/null +++ b/internal/config/jira_import_watchers_test.go @@ -0,0 +1,47 @@ +//go:build test + +package config + +import ( + "embed" + "flag" + "os" + "testing" +) + +// TestLoadJiraImportWatchers is the WI-1983 configuration contract: watcher +// import is off unless an operator explicitly enables it. +func TestLoadJiraImportWatchers(t *testing.T) { + tests := []struct { + name string + env string + want bool + }{ + {name: "unset disables watcher import", want: false}, + {name: "true enables watcher import", env: "true", want: true}, + {name: "1 enables watcher import", env: "1", want: true}, + {name: "false disables watcher import", env: "false", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousCommandLine := flag.CommandLine + previousArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = previousCommandLine + os.Args = previousArgs + }) + + flag.CommandLine = flag.NewFlagSet("windshift-test", flag.ContinueOnError) + os.Args = []string{"windshift-test"} + t.Setenv("SSO_SECRET", "test-session-secret") + t.Setenv("WINDSHIFT_MEMORY_LIMIT_MB", "") + t.Setenv("JIRA_IMPORT_WATCHERS", tt.env) + + cfg := Load(embed.FS{}, make(chan os.Signal, 1)) + if cfg.Jira.ImportWatchers != tt.want { + t.Fatalf("Jira.ImportWatchers = %t, want %t", cfg.Jira.ImportWatchers, tt.want) + } + }) + } +} diff --git a/internal/config/logbook_durable_actions_test.go b/internal/config/logbook_durable_actions_test.go deleted file mode 100644 index e191253..0000000 --- a/internal/config/logbook_durable_actions_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package config - -import "testing" - -func TestLoadLogbookSidecarDurableActionCutover(t *testing.T) { - tests := []struct { - value string - want bool - }{ - {value: "", want: false}, - {value: "false", want: false}, - {value: "true", want: true}, - {value: "1", want: true}, - } - for _, tt := range tests { - t.Run(tt.value, func(t *testing.T) { - t.Setenv("LOGBOOK_ACTIVATE_DURABLE_ACTIONS", tt.value) - if got := LoadLogbookSidecar().ActivateDurableActions; got != tt.want { - t.Fatalf("ActivateDurableActions = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/config/max_user_streams_test.go b/internal/config/max_user_streams_test.go new file mode 100644 index 0000000..d32d3d6 --- /dev/null +++ b/internal/config/max_user_streams_test.go @@ -0,0 +1,46 @@ +//go:build test + +package config + +import ( + "embed" + "flag" + "os" + "testing" +) + +func TestLoadMaxUserStreams(t *testing.T) { + tests := []struct { + name string + args []string + env string + want int + }{ + {name: "default leaves room for several tabs", want: 32}, + {name: "explicit flag", args: []string{"--max-user-streams=12"}, want: 12}, + {name: "environment overrides the flag", args: []string{"--max-user-streams=12"}, env: "5", want: 5}, + {name: "zero disables the cap", env: "0", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousCommandLine := flag.CommandLine + previousArgs := os.Args + t.Cleanup(func() { + flag.CommandLine = previousCommandLine + os.Args = previousArgs + }) + + flag.CommandLine = flag.NewFlagSet("windshift-test", flag.ContinueOnError) + os.Args = append([]string{"windshift-test"}, tt.args...) + t.Setenv("SSO_SECRET", "test-session-secret") + t.Setenv("WINDSHIFT_MEMORY_LIMIT_MB", "") + t.Setenv("MAX_USER_STREAMS", tt.env) + + cfg := Load(embed.FS{}, make(chan os.Signal, 1)) + if cfg.MaxUserStreams != tt.want { + t.Fatalf("MaxUserStreams = %d, want %d", cfg.MaxUserStreams, tt.want) + } + }) + } +} diff --git a/internal/config/outbound_tls_test.go b/internal/config/outbound_tls_test.go index db071e2..512270c 100644 --- a/internal/config/outbound_tls_test.go +++ b/internal/config/outbound_tls_test.go @@ -39,10 +39,6 @@ func TestLoadTLSSkipVerify(t *testing.T) { if cfg.OutboundTLS.SkipVerify != tt.want { t.Fatalf("OutboundTLS.SkipVerify = %v, want %v", cfg.OutboundTLS.SkipVerify, tt.want) } - logbookCfg := LoadLogbookSidecar() - if logbookCfg.OutboundTLS.SkipVerify != tt.want { - t.Fatalf("logbook OutboundTLS.SkipVerify = %v, want %v", logbookCfg.OutboundTLS.SkipVerify, tt.want) - } }) } } diff --git a/internal/customfields/custom_field_keys_test.go b/internal/customfields/custom_field_keys_test.go new file mode 100644 index 0000000..cf17057 --- /dev/null +++ b/internal/customfields/custom_field_keys_test.go @@ -0,0 +1,150 @@ +//go:build test + +package customfields_test + +import ( + "errors" + "reflect" + "testing" + + "windshift/internal/customfields" + "windshift/internal/testutils" +) + +func seedPortalAndOrgFields(t *testing.T, tdb *testutils.TestDB) { + t.Helper() + rows := []struct { + id int + name string + portal bool + org bool + }{ + {1, "Account tier", true, false}, + {2, "Priority", true, false}, + {3, "Priority", true, false}, + {4, "Org field", false, true}, + } + for _, r := range rows { + if _, err := tdb.Exec( + `INSERT INTO custom_field_definitions (id, name, field_type, applies_to_portal_customers, applies_to_customer_organisations) VALUES (?, ?, 'text', ?, ?)`, + r.id, r.name, r.portal, r.org, + ); err != nil { + t.Fatalf("seed field %d: %v", r.id, err) + } + } +} + +func TestCustomFieldNameIndex_Resolve(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + seedPortalAndOrgFields(t, tdb) + + index, err := customfields.LoadCustomFieldNameIndex(tdb, customfields.AppliesToPortalCustomers) + if err != nil { + t.Fatalf("load index: %v", err) + } + + cfv := map[string]any{"Account tier": "gold", "Priority": 2, "Unknown": "x"} + changed, ambiguous := index.Resolve(cfv) + if !changed { + t.Fatal("changed = false, want true") + } + if !reflect.DeepEqual(ambiguous, []string{"Priority"}) { + t.Fatalf("ambiguous = %v, want [Priority]", ambiguous) + } + want := map[string]any{"1": "gold", "Priority": 2, "Unknown": "x"} + if !reflect.DeepEqual(cfv, want) { + t.Fatalf("cfv = %v, want %v", cfv, want) + } +} + +func TestCustomFieldNameIndex_ValidateRejectsAmbiguous(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + seedPortalAndOrgFields(t, tdb) + + index, err := customfields.LoadCustomFieldNameIndex(tdb, customfields.AppliesToPortalCustomers) + if err != nil { + t.Fatalf("load index: %v", err) + } + + // Unique name is rewritten, unknown name dropped. + cfv := map[string]any{"Account tier": "gold", "Unknown": "x"} + if err := index.Validate(cfv); err != nil { + t.Fatalf("validate: %v", err) + } + if !reflect.DeepEqual(cfv, map[string]any{"1": "gold"}) { + t.Fatalf("cfv = %v, want {1: gold}", cfv) + } + + // Ambiguous name is rejected. + ambiguous := map[string]any{"Priority": 2} + err = index.Validate(ambiguous) + var nameErr *customfields.AmbiguousNameError + if !errors.As(err, &nameErr) || nameErr.Name != "Priority" { + t.Fatalf("err = %v, want *AmbiguousNameError{Priority}", err) + } +} + +func TestCustomFieldNameIndex_RebuildMirrors(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + seedPortalAndOrgFields(t, tdb) + + index, err := customfields.LoadCustomFieldNameIndex(tdb, customfields.AppliesToPortalCustomers) + if err != nil { + t.Fatalf("load index: %v", err) + } + + // Unique name gets a mirror from the authoritative id key. + cfv := map[string]any{"1": "gold"} + index.RebuildMirrors(cfv) + if !reflect.DeepEqual(cfv, map[string]any{"1": "gold", "Account tier": "gold"}) { + t.Fatalf("cfv = %v, want id + mirror", cfv) + } + + // Duplicate name gets no mirror. + dup := map[string]any{"2": 2} + index.RebuildMirrors(dup) + if !reflect.DeepEqual(dup, map[string]any{"2": 2}) { + t.Fatalf("duplicate-name cfv = %v, want unchanged", dup) + } + + // A stale mirror is removed when the authoritative id key is gone. + stale := map[string]any{"Account tier": "stale"} + index.RebuildMirrors(stale) + if len(stale) != 0 { + t.Fatalf("stale cfv = %v, want empty", stale) + } + + // An ambiguous name key is left alone. + ambiguous := map[string]any{"Priority": 2} + index.RebuildMirrors(ambiguous) + if !reflect.DeepEqual(ambiguous, map[string]any{"Priority": 2}) { + t.Fatalf("ambiguous cfv = %v, want unchanged", ambiguous) + } +} + +func TestCustomFieldNameIndex_NameAndApplicability(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + seedPortalAndOrgFields(t, tdb) + + portal, err := customfields.LoadCustomFieldNameIndex(tdb, customfields.AppliesToPortalCustomers) + if err != nil { + t.Fatalf("load portal index: %v", err) + } + if name, ok := portal.Name(1); !ok || name != "Account tier" { + t.Fatalf("portal.Name(1) = %q, %v", name, ok) + } + if _, ok := portal.Name(4); ok { + t.Fatal("org-only field must not be in the portal index") + } + + org, err := customfields.LoadCustomFieldNameIndex(tdb, customfields.AppliesToCustomerOrganisations) + if err != nil { + t.Fatalf("load org index: %v", err) + } + if name, ok := org.Name(4); !ok || name != "Org field" { + t.Fatalf("org.Name(4) = %q, %v", name, ok) + } + if _, ok := org.Name(1); ok { + t.Fatal("portal-only field must not be in the org index") + } +} diff --git a/internal/database/customer_cfv_numeric_keys_migration_test.go b/internal/database/customer_cfv_numeric_keys_migration_test.go new file mode 100644 index 0000000..a3dd457 --- /dev/null +++ b/internal/database/customer_cfv_numeric_keys_migration_test.go @@ -0,0 +1,191 @@ +//go:build test + +package database_test + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "windshift/internal/testutils" +) + +const customerCFVNumericKeysVersion = "20261120_customer_cfv_numeric_keys" + +// TestMigration_CustomerCFVNumericKeys_NoDataLoss starts from a 0.8.5 +// checkpoint, seeds the pre-migration name-keyed shapes (including duplicate +// names, an orphan name, an already-id-keyed row, and — on SQLite — corrupt +// JSON), runs only the migration, and asserts every value survives. +func TestMigration_CustomerCFVNumericKeys_NoDataLoss(t *testing.T) { + tdb := testutils.CreateFreshDB(t, false) + seedCanonicalSchemaCheckpoint(t, tdb) + seedMinimalWorkspaces(t, tdb) + seedCustomerCFVFixture(t, tdb) + createSchemaMigrationsTable(t, tdb) + stampAllExcept(t, tdb, customerCFVNumericKeysVersion, "") + + if err := tdb.Initialize(); err != nil { + t.Fatalf("run customer cfv migration: %v", err) + } + + // Unique name -> id key added, name mirror kept, value preserved. + assertCFV(t, tdb, "portal_customers", 1, `{"10":"gold","Account tier":"gold","Priority":2}`) + // Already id-keyed -> mirror refreshed from the authoritative value. + assertCFV(t, tdb, "portal_customers", 2, `{"10":"silver","Account tier":"silver"}`) + // Unknown/orphan name -> left untouched. + assertCFV(t, tdb, "portal_customers", 3, `{"Unknown field":"x"}`) + // Empty object -> unchanged. + assertCFV(t, tdb, "portal_customers", 5, `{}`) + // Duplicate name -> no mirror, existing id key untouched. + assertCFV(t, tdb, "portal_customers", 6, `{"Priority":3,"12":3}`) + + // Applicability is scoped: "Account tier" only applies to portal customers, + // so the organisation row keeps the orphan name key. + assertCFV(t, tdb, "customer_organisations", 1, `{"13":"acme","Org field":"acme"}`) + assertCFV(t, tdb, "customer_organisations", 2, `{"Account tier":"nope"}`) + + if !testutils.IsPostgres() { + // Malformed JSON must survive untouched (Postgres JSONB rejects it at + // write time, so this row only exists on SQLite). + var raw string + if err := tdb.QueryRow(`SELECT custom_field_values FROM portal_customers WHERE id = 4`).Scan(&raw); err != nil { + t.Fatalf("read corrupt row: %v", err) + } + if raw != "{" { + t.Fatalf("corrupt row = %q, want %q", raw, "{") + } + } + + // Idempotency: a second Initialize must not change anything. + if err := tdb.Initialize(); err != nil { + t.Fatalf("repeat initialize: %v", err) + } + assertCFV(t, tdb, "portal_customers", 1, `{"10":"gold","Account tier":"gold","Priority":2}`) + assertCFV(t, tdb, "customer_organisations", 1, `{"13":"acme","Org field":"acme"}`) +} + +func seedCustomerCFVFixture(t *testing.T, tdb *testutils.TestDB) { + t.Helper() + + idColumn := "INTEGER PRIMARY KEY AUTOINCREMENT" + jsonColumn := "TEXT" + if testutils.IsPostgres() { + idColumn = "SERIAL PRIMARY KEY" + jsonColumn = "JSONB" + } + for _, stmt := range []string{ + `CREATE TABLE custom_field_definitions ( + id ` + idColumn + `, + name TEXT NOT NULL, + applies_to_portal_customers BOOLEAN DEFAULT false, + applies_to_customer_organisations BOOLEAN DEFAULT false + )`, + `CREATE TABLE portal_customers ( + id ` + idColumn + `, + name TEXT NOT NULL, + email TEXT, + custom_field_values ` + jsonColumn + ` + )`, + `CREATE TABLE customer_organisations ( + id ` + idColumn + `, + name TEXT NOT NULL, + custom_field_values ` + jsonColumn + ` + )`, + } { + if _, err := tdb.Exec(stmt); err != nil { + t.Fatalf("create fixture table: %v", err) + } + } + + fields := []struct { + id int + name string + portal bool + org bool + }{ + {10, "Account tier", true, false}, + {11, "Priority", true, false}, + {12, "Priority", true, false}, + {13, "Org field", false, true}, + {14, "Old name", true, false}, + } + for _, f := range fields { + if _, err := tdb.Exec( + `INSERT INTO custom_field_definitions (id, name, applies_to_portal_customers, applies_to_customer_organisations) VALUES (?, ?, ?, ?)`, + f.id, f.name, f.portal, f.org, + ); err != nil { + t.Fatalf("seed custom field %d: %v", f.id, err) + } + } + + portalRows := []struct { + id int + cfv string + }{ + {1, `{"Account tier":"gold","Priority":2}`}, + {2, `{"10":"silver"}`}, + {3, `{"Unknown field":"x"}`}, + {5, `{}`}, + {6, `{"Priority":3,"12":3}`}, + } + for _, row := range portalRows { + if _, err := tdb.Exec( + `INSERT INTO portal_customers (id, name, email, custom_field_values) VALUES (?, ?, ?, ?)`, + row.id, fmt.Sprintf("pc-%d", row.id), fmt.Sprintf("pc%d@example.com", row.id), row.cfv, + ); err != nil { + t.Fatalf("seed portal customer %d: %v", row.id, err) + } + } + if !testutils.IsPostgres() { + if _, err := tdb.Exec( + `INSERT INTO portal_customers (id, name, email, custom_field_values) VALUES (4, 'pc-4', 'pc4@example.com', '{')`, + ); err != nil { + t.Fatalf("seed corrupt portal customer: %v", err) + } + } + + orgRows := []struct { + id int + cfv string + }{ + {1, `{"Org field":"acme"}`}, + {2, `{"Account tier":"nope"}`}, + } + for _, row := range orgRows { + if _, err := tdb.Exec( + `INSERT INTO customer_organisations (id, name, custom_field_values) VALUES (?, ?, ?)`, + row.id, fmt.Sprintf("co-%d", row.id), row.cfv, + ); err != nil { + t.Fatalf("seed customer organisation %d: %v", row.id, err) + } + } +} + +// assertCFV compares the stored custom_field_values object with wantJSON, +// parsing both with UseNumber so numeric values compare exactly. +func assertCFV(t *testing.T, tdb *testutils.TestDB, table string, id int, wantJSON string) { + t.Helper() + var raw string + //nolint:gosec // table is a test-controlled literal + if err := tdb.QueryRow(fmt.Sprintf(`SELECT custom_field_values FROM %s WHERE id = ?`, table), id).Scan(&raw); err != nil { + t.Fatalf("read %s %d cfv: %v", table, id, err) + } + got := decodeJSONObject(t, raw) + want := decodeJSONObject(t, wantJSON) + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s %d cfv = %s, want %s", table, id, raw, wantJSON) + } +} + +func decodeJSONObject(t *testing.T, raw string) map[string]any { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var out map[string]any + if err := decoder.Decode(&out); err != nil { + t.Fatalf("decode json %q: %v", raw, err) + } + return out +} diff --git a/internal/database/item_search_index_test.go b/internal/database/item_search_index_test.go new file mode 100644 index 0000000..7ad2de5 --- /dev/null +++ b/internal/database/item_search_index_test.go @@ -0,0 +1,44 @@ +//go:build test + +package database_test + +import ( + "testing" + + "windshift/internal/testutils" +) + +// TestItemSearchIndexExists verifies the WI-413 token-prefix search objects are +// created by a fresh schema init on both engines: the FTS5 table and its sync +// triggers on SQLite, and the valid GIN expression index on PostgreSQL. +func TestItemSearchIndexExists(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + + if db.GetDriverName() == "postgres" { + var valid int + if err := db.QueryRow(` + SELECT COUNT(*) FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'idx_items_search' AND n.nspname = current_schema() AND i.indisvalid + `).Scan(&valid); err != nil { + t.Fatalf("read item search index: %v", err) + } + if valid != 1 { + t.Fatal("idx_items_search is missing or invalid after schema init") + } + return + } + + for _, name := range []string{"items_fts", "items_fts_ai", "items_fts_ad", "items_fts_au"} { + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name = ?`, name).Scan(&count); err != nil { + t.Fatalf("query sqlite_master for %s: %v", name, err) + } + if count != 1 { + t.Fatalf("%s is missing after schema init", name) + } + } +} diff --git a/internal/database/item_search_unaccent_test.go b/internal/database/item_search_unaccent_test.go new file mode 100644 index 0000000..e411148 --- /dev/null +++ b/internal/database/item_search_unaccent_test.go @@ -0,0 +1,88 @@ +//go:build test + +package database_test + +import ( + "testing" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +// TestItemSearchIndexSurvivesUnaccentOutsideSearchPath is the WI-413 regression +// guard: the migration and schema file used to assume the unaccent extension +// was resolvable by name, so an installation that keeps extensions in a schema +// outside search_path failed with "function unaccent(unknown, text) does not +// exist". Installing unaccent in a dedicated schema must still produce a +// working ws_unaccent and a valid index on both the fresh-install (schema file) +// and upgrade (migration) paths. +func TestItemSearchIndexSurvivesUnaccentOutsideSearchPath(t *testing.T) { + tdb := testutils.CreateFreshDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + if db.GetDriverName() != "postgres" { + t.Skip("PostgreSQL-only: unaccent extension schema handling") + } + + if _, err := db.Exec(`CREATE SCHEMA ext`); err != nil { + t.Fatalf("create extension schema: %v", err) + } + if _, err := db.Exec(`CREATE EXTENSION unaccent SCHEMA ext`); err != nil { + t.Fatalf("install unaccent outside search_path: %v", err) + } + + // Fresh install: the schema file defines ws_unaccent and the index. + if err := db.Initialize(); err != nil { + t.Fatalf("initialize with unaccent outside search_path: %v", err) + } + assertItemSearchUnaccent(t, db) + + // Upgrade: Initialize already stamped the migration, so drop the objects + // and run the migration's ApplyPostgres directly. + if _, err := db.Exec(`DROP INDEX IF EXISTS idx_items_search`); err != nil { + t.Fatalf("drop index: %v", err) + } + if _, err := db.Exec(`DROP FUNCTION IF EXISTS ws_unaccent(text)`); err != nil { + t.Fatalf("drop ws_unaccent: %v", err) + } + var migration *database.Migration + for i := range database.Catalog { + if database.Catalog[i].Version == "20261116_item_search_prefix" { + migration = &database.Catalog[i] + break + } + } + if migration == nil { + t.Fatal("item search migration not found in catalog") + } + if migration.ApplyPostgres == nil { + t.Fatal("item search migration has no ApplyPostgres") + } + if err := migration.ApplyPostgres(db); err != nil { + t.Fatalf("apply migration with unaccent outside search_path: %v", err) + } + assertItemSearchUnaccent(t, db) +} + +func assertItemSearchUnaccent(t *testing.T, db database.Database) { + t.Helper() + var folded string + if err := db.QueryRow(`SELECT ws_unaccent('CafÊ')`).Scan(&folded); err != nil { + t.Fatalf("ws_unaccent: %v", err) + } + if folded != "cafe" { + t.Fatalf("ws_unaccent('CafÊ') = %q, want %q", folded, "cafe") + } + + var valid int + if err := db.QueryRow(` + SELECT COUNT(*) FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 'idx_items_search' AND i.indisvalid + `).Scan(&valid); err != nil { + t.Fatalf("read item search index: %v", err) + } + if valid != 1 { + t.Fatal("idx_items_search is missing or invalid") + } +} diff --git a/internal/database/migrations_test.go b/internal/database/migrations_test.go index 4ca5994..cc926e3 100644 --- a/internal/database/migrations_test.go +++ b/internal/database/migrations_test.go @@ -340,6 +340,46 @@ func TestMigration_ExternalKey_UniquePerWorkspace(t *testing.T) { } } +func TestRunPendingMigrations_SkipsCheckQueriesForAppliedMigrations(t *testing.T) { + db := openTestDB(t) + + checks := 0 + check := func(Database) (bool, error) { + checks++ + return false, nil + } + catalog := []Migration{ + { + Version: "test_001_first", + Name: "first", + SQLite: "CREATE TABLE first_widget (id INTEGER PRIMARY KEY)", + CheckSQLiteFn: check, + }, + { + Version: "test_002_second", + Name: "second", + SQLite: "CREATE TABLE second_widget (id INTEGER PRIMARY KEY)", + CheckSQLiteFn: check, + }, + } + + if err := runPendingMigrations(db, catalog); err != nil { + t.Fatalf("first run: %v", err) + } + if checks != 2 { + t.Fatalf("first run check predicates = %d, want 2", checks) + } + + // Every version is stamped on the second run, so the runner must skip them + // from schema_migrations without re-evaluating any idempotency predicate. + if err := runPendingMigrations(db, catalog); err != nil { + t.Fatalf("second run: %v", err) + } + if checks != 2 { + t.Fatalf("second run re-evaluated check predicates: checks = %d, want 2", checks) + } +} + func TestRunPendingMigrations_AbortsOnFailingMigration(t *testing.T) { db := openTestDB(t) diff --git a/internal/database/postgres_version_test.go b/internal/database/postgres_version_test.go new file mode 100644 index 0000000..cef2da4 --- /dev/null +++ b/internal/database/postgres_version_test.go @@ -0,0 +1,48 @@ +//go:build test + +package database + +import ( + "strings" + "testing" +) + +// Windshift refuses PostgreSQL servers older than 15 at connection time so an +// unsupported server fails with a clear message instead of a confusing schema +// or migration error. +func TestRequirePostgresVersion(t *testing.T) { + tests := []struct { + name string + versionNum int + wantErr string + }{ + {name: "15.0 accepted", versionNum: 150000}, + {name: "15.19 accepted", versionNum: 150019}, + {name: "16 accepted", versionNum: 160000}, + {name: "17 accepted", versionNum: 170000}, + {name: "14.24 rejected", versionNum: 140024, wantErr: "PostgreSQL 14.24"}, + {name: "13.0 rejected", versionNum: 130000, wantErr: "PostgreSQL 13.0"}, + {name: "12.0 rejected", versionNum: 120000, wantErr: "PostgreSQL 12.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := requirePostgresVersion(tt.versionNum) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("requirePostgresVersion(%d) = %v, want nil", tt.versionNum, err) + } + return + } + if err == nil { + t.Fatalf("requirePostgresVersion(%d) = nil, want an error", tt.versionNum) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want it to mention %q", err.Error(), tt.wantErr) + } + if !strings.Contains(err.Error(), "requires PostgreSQL 15 or newer") { + t.Fatalf("error = %q, want the minimum version stated", err.Error()) + } + }) + } +} diff --git a/internal/database/retained_migrations_upgrade_test.go b/internal/database/retained_migrations_upgrade_test.go index 7a014fe..b75d5a5 100644 --- a/internal/database/retained_migrations_upgrade_test.go +++ b/internal/database/retained_migrations_upgrade_test.go @@ -363,6 +363,52 @@ func TestMigration_JiraImportJobHiddenAt_AddsColumn(t *testing.T) { } } +func TestMigration_WorkflowTransitionScreen_AddsColumn(t *testing.T) { + tdb := testutils.CreateFreshDB(t, false) + seedCanonicalSchemaCheckpoint(t, tdb) + seedMinimalWorkspaces(t, tdb) + + // The migration binds workflow_transitions.screen_id to screens, so both + // the screens table and the pre-migration transitions shape must exist. + idColumn := "INTEGER PRIMARY KEY AUTOINCREMENT" + if testutils.IsPostgres() { + idColumn = "SERIAL PRIMARY KEY" + } + if _, err := tdb.Exec(`CREATE TABLE screens (id ` + idColumn + `, name TEXT NOT NULL)`); err != nil { + t.Fatalf("seed legacy screens: %v", err) + } + if _, err := tdb.Exec(`CREATE TABLE workflow_transitions ( + id ` + idColumn + `, + workflow_id INTEGER NOT NULL, + from_status_id INTEGER, + to_status_id INTEGER NOT NULL, + from_all_statuses BOOLEAN NOT NULL DEFAULT false, + display_order INTEGER DEFAULT 0, + source_handle TEXT, + target_handle TEXT + )`); err != nil { + t.Fatalf("seed legacy workflow_transitions: %v", err) + } + + createSchemaMigrationsTable(t, tdb) + stampAllExcept(t, tdb, "20261119_workflow_transition_screen", "") + + if err := tdb.Initialize(); err != nil { + t.Fatalf("run workflow transition screen migration: %v", err) + } + if count := countFixtureRows(t, tdb, columnPresentQuery("workflow_transitions", "screen_id")); count != 1 { + t.Fatal("screen_id column absent after migration") + } + + // Idempotent: re-running the catalog must not attempt the ALTER again. + if err := tdb.Initialize(); err != nil { + t.Fatalf("repeat initialize: %v", err) + } + if count := countFixtureRows(t, tdb, columnPresentQuery("workflow_transitions", "screen_id")); count != 1 { + t.Fatal("screen_id column absent after repeat initialize") + } +} + // TestCompactCatalogContainsExactlyRetainedMigrations pins the squashed // catalog: the fresh-install baseline marker plus the retained post-0.8.5 // upgrades, in catalog order. @@ -451,6 +497,9 @@ func TestCompactCatalogContainsExactlyRetainedMigrations(t *testing.T) { "20261012_transactional_email_copy", "20261013_portal_reply_markdown", "20261014_notification_digest_links", + "20261121_notification_template_customized", + "20261122_portal_customer_language", + "20261123_notification_template_translations", "20261015_request_types_kind", "20261016_email_outbox_reply_to", "20261018_intake_split", @@ -476,12 +525,24 @@ func TestCompactCatalogContainsExactlyRetainedMigrations(t *testing.T) { "20261107_custom_widgets", "20261108_jira_connection_soft_delete", "20261109_jira_import_job_hidden_at", + "20261109_milestone_comments", + "20261109_milestone_activity", + "20261109_milestone_last_activity", "20261110_intake_import_existing_mail", "20261111_queues_collection_scope", "20261112_audit_logs_source", "20261113_item_due_reminders", "20261114_user_due_date_reminder_prefs", "20261115_remove_personal_labels", + "20261116_item_search_prefix", + "20261117_time_project_customer_backfill", + "20261118_drop_time_worklog_customer", + "20261119_workflow_transition_screen", + "20261120_customer_cfv_numeric_keys", + "20261120_integration_oauth_pkce_refresh", + "20261121_item_due_reminders_delivered", + "20261121_theme_branding", + "20261122_plane_import_mappings", } if len(database.Catalog) != len(want) { t.Fatalf("catalog length = %d, want %d", len(database.Catalog), len(want)) diff --git a/internal/database/theme_branding_migration_test.go b/internal/database/theme_branding_migration_test.go new file mode 100644 index 0000000..b716dbb --- /dev/null +++ b/internal/database/theme_branding_migration_test.go @@ -0,0 +1,86 @@ +//go:build test + +package database + +import ( + "fmt" + "testing" +) + +func TestMigrationThemeBrandingAddsFullLogoAndLayoutColumns(t *testing.T) { + dsn := fmt.Sprintf("file:%s/theme-branding.db?mode=memory&cache=shared", t.TempDir()) + db, err := NewSQLiteDBWithPoolSizes(dsn, 2, 1) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer func() { _ = db.Close() }() + + // Fixture represents a database that applied the light and dark logo + // migrations but not the brand-layout/full-logo one. + for _, statement := range []string{ + `CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + builtin_key TEXT, + name TEXT NOT NULL, + description TEXT, + is_default BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT false, + nav_background_color_light TEXT NOT NULL DEFAULT '#ffffff', + nav_text_color_light TEXT NOT NULL DEFAULT '#374151', + nav_background_color_dark TEXT NOT NULL DEFAULT '#1f2937', + nav_text_color_dark TEXT NOT NULL DEFAULT '#f3f4f6', + logo_url TEXT, + logo_url_dark TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `INSERT INTO themes (name, is_default, is_active, logo_url, logo_url_dark) VALUES ('Default', true, true, '/api/portal-assets/1', '/api/portal-assets/2')`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("legacy fixture: %v", err) + } + } + + var migration *Migration + for i := range Catalog { + if Catalog[i].Version == "20261121_theme_branding" { + migration = &Catalog[i] + break + } + } + if migration == nil { + t.Fatal("theme branding migration missing from catalog") + } + if err := applyMigration(db, driverSQLite, *migration); err != nil { + t.Fatalf("apply migration: %v", err) + } + + // Existing rows keep their logos, get NULL full logos, and default to the + // mark + text layout. + var logoURL, logoURLDark string + var logoFullURL, logoFullURLDark *string + var brandLayout string + if err := db.QueryRow( + `SELECT COALESCE(logo_url, ''), COALESCE(logo_url_dark, ''), logo_full_url, logo_full_url_dark, brand_layout FROM themes WHERE name = 'Default'`, + ).Scan(&logoURL, &logoURLDark, &logoFullURL, &logoFullURLDark, &brandLayout); err != nil { + t.Fatalf("read migrated row: %v", err) + } + if logoURL != "/api/portal-assets/1" || logoURLDark != "/api/portal-assets/2" { + t.Fatalf("existing logos = %q / %q, want preserved", logoURL, logoURLDark) + } + if logoFullURL != nil || logoFullURLDark != nil { + t.Fatalf("full logos = %v / %v, want NULL", logoFullURL, logoFullURLDark) + } + if brandLayout != "mark_text" { + t.Fatalf("brand_layout = %q, want mark_text", brandLayout) + } + if _, err := db.Exec(`UPDATE themes SET logo_full_url = '/api/portal-assets/3', brand_layout = 'full_logo' WHERE name = 'Default'`); err != nil { + t.Fatalf("write brand fields after migration: %v", err) + } +} diff --git a/internal/database/time_worklog_customer_drop_migration_test.go b/internal/database/time_worklog_customer_drop_migration_test.go new file mode 100644 index 0000000..5fc40d7 --- /dev/null +++ b/internal/database/time_worklog_customer_drop_migration_test.go @@ -0,0 +1,164 @@ +//go:build test + +package database_test + +import ( + "strconv" + "testing" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +// seedLegacyWorklogCustomerColumn recreates the pre-migration time_worklogs +// shape: a customer_id column with its index. The fresh schema no longer has +// it and the API cannot create a stale column, so direct SQL is the only way +// to model the upgrade state. +func seedLegacyWorklogCustomerColumn(t *testing.T, tdb *testutils.TestDB) { + t.Helper() + if _, err := tdb.Exec(`ALTER TABLE time_worklogs ADD COLUMN customer_id INTEGER`); err != nil { + t.Fatalf("add legacy customer_id column: %v", err) + } + if _, err := tdb.Exec(`CREATE INDEX idx_time_worklogs_customer_id ON time_worklogs(customer_id)`); err != nil { + t.Fatalf("create legacy customer index: %v", err) + } +} + +func timeWorklogIndexCount(t *testing.T, tdb *testutils.TestDB, name string) int { + t.Helper() + query := `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name='time_worklogs' AND name='` + name + `'` + if testutils.IsPostgres() { + query = `SELECT COUNT(*) FROM pg_indexes WHERE schemaname=current_schema() AND tablename='time_worklogs' AND indexname='` + name + `'` + } + return countFixtureRows(t, tdb, query) +} + +// TestMigration_TimeWorklogCustomer_DropsColumnAndPreservesRows upgrades a +// database that still stores the worklog customer reference and proves the +// migration removes the column and its index without losing worklog rows. +func TestMigration_TimeWorklogCustomer_DropsColumnAndPreservesRows(t *testing.T) { + tdb := testutils.CreateTestDB(t, false) + t.Cleanup(func() { _ = tdb.Close() }) + + orgID := testutils.InsertID(t, tdb, `INSERT INTO customer_organisations (name) VALUES ('Legacy customer')`) + projectID := testutils.InsertID(t, tdb, `INSERT INTO time_projects (customer_id, name, status) VALUES (?, 'Legacy project', 'Active')`, orgID) + userID := testutils.InsertID(t, tdb, `INSERT INTO users (email, username, first_name, last_name) VALUES ('legacy-worklog@example.test', 'legacy-worklog', 'Legacy', 'Worklog')`) + worklogID := testutils.InsertID(t, tdb, ` + INSERT INTO time_worklogs (project_id, user_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) + VALUES (?, ?, 'Legacy entry', 100, 100, 3700, 60, 100, 100) + `, projectID, userID) + + seedLegacyWorklogCustomerColumn(t, tdb) + if _, err := tdb.Exec(`UPDATE time_worklogs SET customer_id = ? WHERE id = ?`, orgID, worklogID); err != nil { + t.Fatalf("stamp legacy customer: %v", err) + } + + // Make the customer-model migrations pending so Initialize runs them. + for _, version := range []string{"20261117_time_project_customer_backfill", "20261118_drop_time_worklog_customer"} { + if _, err := tdb.Exec(`DELETE FROM schema_migrations WHERE version = ?`, version); err != nil { + t.Fatalf("clear migration %s: %v", version, err) + } + } + + if err := tdb.Initialize(); err != nil { + t.Fatalf("run time worklog customer migrations: %v", err) + } + if err := tdb.Initialize(); err != nil { + t.Fatalf("repeat initialize: %v", err) + } + + if got := countFixtureRows(t, tdb, columnPresentQuery("time_worklogs", "customer_id")); got != 0 { + t.Fatalf("time_worklogs still has customer_id after migration") + } + if got := countFixtureRows(t, tdb, `SELECT COUNT(*) FROM time_worklogs WHERE id = `+strconv.Itoa(worklogID)); got != 1 { + t.Fatalf("worklog rows after migration = %d, want 1", got) + } + for _, index := range []string{"idx_time_worklogs_project_id", "idx_time_worklogs_date", "idx_time_worklogs_item_id", "idx_time_worklogs_user_id"} { + if got := timeWorklogIndexCount(t, tdb, index); got != 1 { + t.Fatalf("index %s after migration = %d, want 1", index, got) + } + } + if got := timeWorklogIndexCount(t, tdb, "idx_time_worklogs_customer_id"); got != 0 { + t.Fatalf("idx_time_worklogs_customer_id after migration = %d, want 0", got) + } + + // Deleting the organisation must no longer cascade into worklogs. + if _, err := tdb.Exec(`DELETE FROM customer_organisations WHERE id = ?`, orgID); err != nil { + t.Fatalf("delete organisation: %v", err) + } + if got := countFixtureRows(t, tdb, `SELECT COUNT(*) FROM time_worklogs`); got != 1 { + t.Fatalf("worklog rows after organisation delete = %d, want 1", got) + } +} + +// TestMigration_TimeProjectCustomer_BackfillsNulls proves the backfill moves +// legacy NULL project customers onto the built-in no-customer placeholder. +func TestMigration_TimeProjectCustomer_BackfillsNulls(t *testing.T) { + tdb := testutils.CreateTestDB(t, false) + t.Cleanup(func() { _ = tdb.Close() }) + + var noCustomerID int + if err := tdb.QueryRow(`SELECT id FROM customer_organisations WHERE builtin_key = 'no_customer'`).Scan(&noCustomerID); err != nil { + t.Fatalf("load no-customer organisation: %v", err) + } + projectID := testutils.InsertID(t, tdb, `INSERT INTO time_projects (customer_id, name, status) VALUES (NULL, 'Orphan project', 'Active')`) + + if _, err := tdb.Exec(`DELETE FROM schema_migrations WHERE version = '20261117_time_project_customer_backfill'`); err != nil { + t.Fatalf("clear backfill migration: %v", err) + } + if err := tdb.Initialize(); err != nil { + t.Fatalf("run project customer backfill: %v", err) + } + + var got int + if err := tdb.QueryRow(`SELECT customer_id FROM time_projects WHERE id = ?`, projectID).Scan(&got); err != nil { + t.Fatalf("read backfilled customer: %v", err) + } + if got != noCustomerID { + t.Fatalf("backfilled customer_id = %d, want no-customer %d", got, noCustomerID) + } +} + +// TestMigration_TimeWorklogCustomer_FreshInstallsStampWithoutRunning asserts +// the new checks report the effect present on a fresh install, so the runner +// stamps them instead of rebuilding a table that never had the column. +func TestMigration_TimeWorklogCustomer_FreshInstallsStampWithoutRunning(t *testing.T) { + fresh := testutils.CreateTestDB(t, false) + t.Cleanup(func() { _ = fresh.Close() }) + + for _, version := range []string{"20261117_time_project_customer_backfill", "20261118_drop_time_worklog_customer"} { + migration := findMigration(t, version) + check := migration.CheckSQLite + if testutils.IsPostgres() { + check = migration.CheckPostgres + } + var applied int + if err := fresh.QueryRow(check).Scan(&applied); err != nil { + t.Fatalf("run %s fresh-install check: %v", version, err) + } + if applied != 1 { + t.Fatalf("%s fresh-install check reported %d, want 1 (effect already present)", version, applied) + } + var stamps int + if err := fresh.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&stamps); err != nil { + t.Fatal(err) + } + if stamps != 1 { + t.Fatalf("%s migration stamps = %d, want 1", version, stamps) + } + } + if got := countFixtureRows(t, fresh, columnPresentQuery("time_worklogs", "customer_id")); got != 0 { + t.Fatalf("fresh install time_worklogs has customer_id") + } +} + +func findMigration(t *testing.T, version string) *database.Migration { + t.Helper() + for i := range database.Catalog { + if database.Catalog[i].Version == version { + return &database.Catalog[i] + } + } + t.Fatalf("%s missing from the migration catalog", version) + return nil +} diff --git a/internal/handlers/accept_dependencies_batch_test.go b/internal/handlers/accept_dependencies_batch_test.go new file mode 100644 index 0000000..a93b02c --- /dev/null +++ b/internal/handlers/accept_dependencies_batch_test.go @@ -0,0 +1,115 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "windshift/internal/contextkeys" + "windshift/internal/database" + "windshift/internal/models" + "windshift/internal/services" + "windshift/internal/testutils" +) + +// WI-1909: AcceptDependencies used to resolve the source and target item +// workspaces with two queries per suggestion. It must now resolve every +// referenced item in one batched query. + +func grantSystemAdminForBatchTest(t *testing.T, db database.Database, userID int) { + t.Helper() + if _, err := db.Exec(` + INSERT INTO user_global_permissions (user_id, permission_id, granted_by, granted_at) + VALUES (?, (SELECT id FROM permissions WHERE permission_key = 'system.admin'), ?, ?) + `, userID, userID, time.Now().UTC()); err != nil { + t.Fatalf("grant system admin: %v", err) + } +} + +func insertBatchTestItemFor(t *testing.T, db database.Database, seed testutils.TestDataSet, number int) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, + status_id, priority_id, frac_index, created_at, updated_at + ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?) + `, seed.WorkspaceID, number, "n+1 dependency item", + seed.StatusID, seed.PriorityID, testutils.NextTestFracIndex(), + time.Now().UTC(), time.Now().UTC()) +} + +func TestAcceptDependenciesResolvesWorkspacesInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + grantSystemAdminForBatchTest(t, db, seed.UserID) + + linkTypeID := testutils.InsertID(t, db, + `INSERT INTO link_types (name, forward_label, reverse_label) VALUES (?, ?, ?)`, + "n+1 dependency", "depends on", "blocks") + + const suggestionCount = 6 + type itemPair struct{ source, target int } + pairs := make([]itemPair, 0, suggestionCount) + for i := 0; i < suggestionCount; i++ { + source := insertBatchTestItemFor(t, db, seed, i*2+1) + target := insertBatchTestItemFor(t, db, seed, i*2+2) + pairs = append(pairs, itemPair{source: source, target: target}) + } + + counting := testutils.CountQueries(db) + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permService, err := services.NewPermissionService(counting, config) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + t.Cleanup(func() { _ = permService.Close() }) + + handler := &AIHandler{db: counting, permService: permService} + + encoded := make([]string, 0, suggestionCount) + for _, pair := range pairs { + encoded = append(encoded, fmt.Sprintf( + `{"source_item_id":%d,"target_item_id":%d,"link_type_id":%d}`, + pair.source, pair.target, linkTypeID, + )) + } + body := `{"suggestions":[` + strings.Join(encoded, ",") + `]}` + + req := httptest.NewRequest(http.MethodPost, "/api/ai/items/1/dependencies/accept", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "1") + req = req.WithContext(context.WithValue(req.Context(), contextkeys.User, &models.User{ID: seed.UserID})) + recorder := httptest.NewRecorder() + + counting.ResetQueries() + handler.AcceptDependencies(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var resp AcceptDependenciesResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v (body %s)", err, recorder.Body.String()) + } + if resp.Created != suggestionCount || resp.Skipped != 0 { + t.Fatalf("created=%d skipped=%d, want created=%d skipped=0", resp.Created, resp.Skipped, suggestionCount) + } + + if got := counting.CountQueriesContaining("SELECT workspace_id FROM items WHERE id = ?"); got != 0 { + t.Fatalf("handler still issues %d per-item workspace lookups", got) + } + if got := counting.CountQueriesContaining("SELECT id, workspace_id FROM items WHERE id IN ("); got != 1 { + t.Fatalf("batched workspace lookup ran %d times, want 1", got) + } +} diff --git a/internal/handlers/agent_conversation_handler_test.go b/internal/handlers/agent_conversation_handler_test.go index 2610573..c98965c 100644 --- a/internal/handlers/agent_conversation_handler_test.go +++ b/internal/handlers/agent_conversation_handler_test.go @@ -16,6 +16,7 @@ import ( "windshift/internal/contextkeys" "windshift/internal/database" "windshift/internal/llm" + "windshift/internal/logger" "windshift/internal/models" "windshift/internal/repository" "windshift/internal/services" @@ -234,7 +235,7 @@ func TestAuditAgentTranscriptResolvesCorrelationWithoutParticipantAccess(t *test `).Scan(&auditID); err != nil { t.Fatal(err) } - auditHandler := NewAuditLogHandler(repository.NewAuditLogRepository(db)) + auditHandler := NewAuditLogHandler(repository.NewAuditLogRepository(db), logger.NewAuditor(db)) auditHandler.SetAgentTranscriptRepositories( repository.NewAgentConversationRepository(db), repository.NewAgentRunRepository(db), diff --git a/internal/handlers/ai_llm_error_test.go b/internal/handlers/ai_llm_error_test.go new file mode 100644 index 0000000..fa64967 --- /dev/null +++ b/internal/handlers/ai_llm_error_test.go @@ -0,0 +1,142 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "windshift/internal/database" + "windshift/internal/llm" +) + +// failingChatClient returns a provider-style rejection for every completion. +type failingChatClient struct { + err error +} + +func (c failingChatClient) Complete(context.Context, llm.CompletionRequest) (*llm.CompletionResponse, error) { + return nil, c.err +} + +func (failingChatClient) Health(context.Context) error { return nil } +func (failingChatClient) Available() bool { return true } + +const providerRejection = "temperature is deprecated for this model" + +// grantChatAdmin makes the user a real system admin through the permission +// service, which is what the handler consults (the session user's cached flag +// is not reliable). +func grantChatAdmin(t *testing.T, handler *AIHandler, db database.Database, userID int) { + t.Helper() + if _, err := db.Exec(` + INSERT INTO user_global_permissions(user_id, permission_id) + SELECT ?, id FROM permissions WHERE permission_key = 'system.admin' + `, userID); err != nil { + t.Fatal(err) + } + if err := handler.permService.InvalidateUserCache(userID); err != nil { + t.Fatal(err) + } +} + +// The provider's rejection must be persisted on the failed run and returned to +// system admins so a rejected chat is diagnosable without a proxy capture. +func TestAIChatPersistsProviderErrorAndReturnsItToAdmins(t *testing.T) { + handler, user, db, _ := newConversationHandler(t) + handler.chatLLMs = fixedChatResolver{client: failingChatClient{err: errors.New(providerRejection)}} + grantChatAdmin(t, handler, db, user.ID) + + recorder := chatRequest(t, handler, user, `{"message":"why did this fail","context":{"workspace_id":1}}`) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("chat status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + var body struct { + Error string `json:"error"` + Details map[string]string `json:"details"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Error != "The AI service could not complete this request." { + t.Fatalf("error message=%q", body.Error) + } + if !strings.Contains(body.Details["provider_error"], providerRejection) { + t.Fatalf("provider_error=%q want it to contain %q", body.Details["provider_error"], providerRejection) + } + + var stored string + if err := db.QueryRow( + `SELECT error FROM agent_runs WHERE status='failed' ORDER BY id DESC LIMIT 1`, + ).Scan(&stored); err != nil { + t.Fatal(err) + } + if !strings.Contains(stored, providerRejection) { + t.Fatalf("persisted agent_runs.error=%q want it to contain %q", stored, providerRejection) + } +} + +// A non-admin must not receive the raw provider message, but the run still +// records it for admins inspecting history. +func TestAIChatHidesProviderErrorFromNonAdmins(t *testing.T) { + handler, user, db, _ := newConversationHandler(t) + handler.chatLLMs = fixedChatResolver{client: failingChatClient{err: errors.New(providerRejection)}} + + recorder := chatRequest(t, handler, user, `{"message":"why did this fail","context":{"workspace_id":1}}`) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("chat status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + var body struct { + Details map[string]string `json:"details"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if _, ok := body.Details["provider_error"]; ok { + t.Fatalf("non-admin received provider_error: %v", body.Details) + } + + var stored string + if err := db.QueryRow( + `SELECT error FROM agent_runs WHERE status='failed' ORDER BY id DESC LIMIT 1`, + ).Scan(&stored); err != nil { + t.Fatal(err) + } + if !strings.Contains(stored, providerRejection) { + t.Fatalf("persisted agent_runs.error=%q want it to contain %q", stored, providerRejection) + } +} + +// Credential-shaped text in a provider error must be scrubbed before it is +// logged, returned, or persisted. +func TestAIChatRedactsProviderError(t *testing.T) { + handler, user, db, _ := newConversationHandler(t) + handler.chatLLMs = fixedChatResolver{ + client: failingChatClient{err: errors.New("upstream rejected https://user:secret@example.test/v1 with Authorization: Bearer sk-live-1234567890")}, + } + grantChatAdmin(t, handler, db, user.ID) + + recorder := chatRequest(t, handler, user, `{"message":"why did this fail","context":{"workspace_id":1}}`) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("chat status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), "secret") || strings.Contains(recorder.Body.String(), "sk-live-1234567890") { + t.Fatalf("credential leaked in response: %s", recorder.Body.String()) + } + + var stored string + if err := db.QueryRow( + `SELECT error FROM agent_runs WHERE status='failed' ORDER BY id DESC LIMIT 1`, + ).Scan(&stored); err != nil { + t.Fatal(err) + } + if strings.Contains(stored, "secret") || strings.Contains(stored, "sk-live-1234567890") { + t.Fatalf("credential leaked in persisted run error: %s", stored) + } +} diff --git a/internal/handlers/attachment_classification_test.go b/internal/handlers/attachment_classification_test.go index 7644876..2a27685 100644 --- a/internal/handlers/attachment_classification_test.go +++ b/internal/handlers/attachment_classification_test.go @@ -17,6 +17,7 @@ func TestClassifyAttachment(t *testing.T) { wantScoped bool wantDomain bool wantImageOnly bool + wantAllowSVG bool wantRequiresID bool wantPublicAsset bool wantURLKey string @@ -36,7 +37,7 @@ func TestClassifyAttachment(t *testing.T) { {name: "workspace background", entityType: "workspace_background", wantEntity: "workspace_background", wantCollection: "workspace_backgrounds", wantImageOnly: true, wantRequiresID: true, wantURLKey: "background_url"}, {name: "portal background", entityType: "portal_background", wantEntity: "portal_background", wantCollection: "portal_backgrounds", wantImageOnly: true, wantRequiresID: true, wantPublicAsset: true, wantURLKey: "background_url"}, {name: "hub logo", entityType: "hub_logo", wantEntity: "hub_logo", wantCollection: "hub_logos", wantImageOnly: true, wantPublicAsset: true, wantURLKey: "logo_url"}, - {name: "theme logo", entityType: "theme_logo", wantEntity: "theme_logo", wantCollection: "theme_logos", wantImageOnly: true, wantPublicAsset: true, wantURLKey: "logo_url"}, + {name: "theme logo", entityType: "theme_logo", wantEntity: "theme_logo", wantCollection: "theme_logos", wantImageOnly: true, wantAllowSVG: true, wantPublicAsset: true, wantURLKey: "logo_url"}, {name: "unknown entity type", entityType: "not-a-type", wantErr: true}, } @@ -56,10 +57,11 @@ func TestClassifyAttachment(t *testing.T) { t.Fatalf("kind = %+v, want entity=%q collection=%q", kind, tc.wantEntity, tc.wantCollection) } if kind.scopedByEntity != tc.wantScoped || kind.domain != tc.wantDomain || - kind.imageOnly != tc.wantImageOnly || kind.requiresEntityID != tc.wantRequiresID || + kind.imageOnly != tc.wantImageOnly || kind.allowSVG != tc.wantAllowSVG || + kind.requiresEntityID != tc.wantRequiresID || kind.publicAsset != tc.wantPublicAsset || kind.urlKey != tc.wantURLKey { - t.Fatalf("kind = %+v, want scoped=%v domain=%v image=%v requiresID=%v public=%v urlKey=%q", - kind, tc.wantScoped, tc.wantDomain, tc.wantImageOnly, tc.wantRequiresID, tc.wantPublicAsset, tc.wantURLKey) + t.Fatalf("kind = %+v, want scoped=%v domain=%v image=%v allowSVG=%v requiresID=%v public=%v urlKey=%q", + kind, tc.wantScoped, tc.wantDomain, tc.wantImageOnly, tc.wantAllowSVG, tc.wantRequiresID, tc.wantPublicAsset, tc.wantURLKey) } }) } diff --git a/internal/handlers/attachment_customer_avatar_test.go b/internal/handlers/attachment_customer_avatar_test.go new file mode 100644 index 0000000..6bf3877 --- /dev/null +++ b/internal/handlers/attachment_customer_avatar_test.go @@ -0,0 +1,154 @@ +//go:build test + +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "windshift/internal/blobstore" + "windshift/internal/models" + "windshift/internal/services" + "windshift/internal/testutils" +) + +// onePixelPNG is a valid 1x1 PNG. DetectContentType reports image/png and the +// image-only upload path accepts it. +var onePixelPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +} + +func customerAvatarUploadRequest(t *testing.T, entityID string, user *models.User) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("entity_id", entityID); err != nil { + t.Fatalf("write entity_id: %v", err) + } + if err := writer.WriteField("category", "customer_avatar"); err != nil { + t.Fatalf("write category: %v", err) + } + part, err := writer.CreateFormFile("file", "avatar.png") + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := part.Write(onePixelPNG); err != nil { + t.Fatalf("write png: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/attachments/upload", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return testutils.WithAuthContext(req, user) +} + +// TestAttachmentUpload_CustomerAvatar pins the create/edit upload contract for +// organisation avatars. The organisation does not exist yet while creating it, +// so a staged upload with entity_id 0 must be allowed for a caller holding +// customers.manage; a real entity_id must still name an existing organisation. +func TestAttachmentUpload_CustomerAvatar(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + db := tdb.GetDatabase() + + managerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('avatar-manager@example.test', 'avatar-manager', 'Avatar', 'Manager')`) + if _, err := db.ExecWrite(`INSERT INTO user_global_permissions (user_id, permission_id) SELECT ?, id FROM permissions WHERE permission_key = 'customers.manage'`, managerID); err != nil { + t.Fatalf("grant customers.manage: %v", err) + } + manager := &models.User{ID: managerID, Username: "avatar-manager", IsActive: true} + + otherID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('avatar-other@example.test', 'avatar-other', 'Avatar', 'Other')`) + other := &models.User{ID: otherID, Username: "avatar-other", IsActive: true} + + customerID := testutils.InsertID(t, db, `INSERT INTO customer_organisations (name) VALUES ('Acme')`) + + permService, err := services.NewPermissionService(db, services.PermissionCacheConfig{ + TTL: time.Minute, MaxCacheSize: 8, WarmupOnStartup: false, PreWarmActive: false, BatchSize: 10, + }) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + t.Cleanup(func() { _ = permService.Close() }) + + handler := NewAttachmentHandler(db, blobstore.NewLocal(t.TempDir()), permService) + + upload := func(entityID string, user *models.User) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler.Upload(rec, customerAvatarUploadRequest(t, entityID, user)) + return rec + } + + t.Run("staged upload before the organisation exists succeeds", func(t *testing.T) { + rec := upload("0", manager) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + var resp struct { + Success bool `json:"success"` + AvatarURL string `json:"avatar_url"` + Attachment int64 `json:"attachment_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.Success || resp.AvatarURL == "" { + t.Fatalf("unexpected response: %s", rec.Body.String()) + } + + var entityType string + var itemID sql.NullInt64 + if err := db.QueryRow(`SELECT entity_type, item_id FROM attachments WHERE id = ?`, resp.Attachment).Scan(&entityType, &itemID); err != nil { + t.Fatalf("load attachment: %v", err) + } + if entityType != "customer_avatar" { + t.Fatalf("entity_type = %q, want customer_avatar", entityType) + } + if itemID.Valid { + t.Fatalf("item_id = %d, want NULL", itemID.Int64) + } + }) + + t.Run("upload for an existing organisation succeeds", func(t *testing.T) { + rec := upload(strconv.Itoa(customerID), manager) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + }) + + t.Run("unknown organisation is rejected", func(t *testing.T) { + rec := upload("999999", manager) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d (body=%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "customer not found") { + t.Fatalf("body = %s, want customer not found", rec.Body.String()) + } + }) + + t.Run("missing customers.manage is denied", func(t *testing.T) { + rec := upload("0", other) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d (body=%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "customer not found") { + t.Fatalf("body = %s, want customer not found", rec.Body.String()) + } + }) +} diff --git a/internal/handlers/attachment_svg_test.go b/internal/handlers/attachment_svg_test.go new file mode 100644 index 0000000..26041fe --- /dev/null +++ b/internal/handlers/attachment_svg_test.go @@ -0,0 +1,302 @@ +//go:build test + +package handlers + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/models" + "windshift/internal/services" + "windshift/internal/testutils" +) + +// validThemeLogoSVG is a well-formed, self-contained SVG used across the +// theme-logo upload and serving tests. +const validThemeLogoSVG = ` +` + +// svgMultipartUploadRequest builds a multipart upload for an arbitrary entity +// type, filename, and payload. extra adds any additional form fields. +func svgMultipartUploadRequest(t *testing.T, entityType, filename string, content []byte, extra map[string]string) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if entityType != "" { + if err := writer.WriteField("entity_type", entityType); err != nil { + t.Fatalf("write entity_type field: %v", err) + } + } + for key, value := range extra { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("write %s field: %v", key, err) + } + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write file part: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/attachments/upload", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestValidateFileExtension_SVGOptIn(t *testing.T) { + h := &AttachmentHandler{} + cases := []struct { + name string + filename string + allowSVG bool + wantErr bool + }{ + {name: "svg blocked by default", filename: "logo.svg", wantErr: true}, + {name: "svg allowed when opted in", filename: "logo.svg", allowSVG: true}, + {name: "uppercase svg allowed when opted in", filename: "LOGO.SVG", allowSVG: true}, + {name: "png allowed", filename: "logo.png"}, + {name: "html never allowed", filename: "logo.html", allowSVG: true, wantErr: true}, + {name: "exe never allowed", filename: "logo.exe", allowSVG: true, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := h.validateFileExtension(tc.filename, tc.allowSVG) + if tc.wantErr && err == nil { + t.Fatalf("validateFileExtension(%q, %v) = nil, want an error", tc.filename, tc.allowSVG) + } + if !tc.wantErr && err != nil { + t.Fatalf("validateFileExtension(%q, %v) = %v, want nil", tc.filename, tc.allowSVG, err) + } + }) + } +} + +func TestIsAllowedImageExtension_SVGOptIn(t *testing.T) { + cases := []struct { + filename string + allowSVG bool + want bool + }{ + {filename: "logo.svg", want: false}, + {filename: "logo.svg", allowSVG: true, want: true}, + {filename: "LOGO.SVG", allowSVG: true, want: true}, + {filename: "logo.png", want: true}, + {filename: "logo.exe", allowSVG: true, want: false}, + } + for _, tc := range cases { + if got := isAllowedImageExtension(tc.filename, tc.allowSVG); got != tc.want { + t.Fatalf("isAllowedImageExtension(%q, %v) = %v, want %v", tc.filename, tc.allowSVG, got, tc.want) + } + } +} + +func TestLooksLikeSVG(t *testing.T) { + cases := []struct { + name string + data string + want bool + }{ + {name: "plain svg", data: ``, want: true}, + {name: "xml declaration and doctype", data: ``, want: true}, + {name: "no namespace svg", data: ``, want: true}, + {name: "wrong namespace", data: ``, want: false}, + {name: "html root", data: `hi`, want: false}, + {name: "not xml", data: `not xml at all`, want: false}, + {name: "empty", data: ``, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := looksLikeSVG([]byte(tc.data)); got != tc.want { + t.Fatalf("looksLikeSVG(%q) = %v, want %v", tc.data, got, tc.want) + } + }) + } +} + +// TestVerifyFileContentFromBytes_SVG pins the content sniffing contract: SVG +// is XML, so it must be recognised from its root element rather than the +// text/xml type http.DetectContentType reports, and non-SVG bytes carrying an +// .svg extension must be rejected. +func TestVerifyFileContentFromBytes_SVG(t *testing.T) { + h := &AttachmentHandler{} + + got, err := h.verifyFileContentFromBytes([]byte(validThemeLogoSVG), "logo.svg") + if err != nil { + t.Fatalf("verify SVG: %v", err) + } + if got != "image/svg+xml" { + t.Fatalf("detected type = %q, want image/svg+xml", got) + } + + if _, err := h.verifyFileContentFromBytes([]byte(`hi`), "logo.svg"); err == nil { + t.Fatal("HTML bytes named .svg were accepted, want a rejection") + } + + png := tinyPNG(t) + if got, err := h.verifyFileContentFromBytes(png, "logo.png"); err != nil || got != "image/png" { + t.Fatalf("verify PNG = (%q, %v), want (image/png, nil)", got, err) + } +} + +// TestUploadThemeLogo_AcceptsSVGAndServesSandboxed is the end-to-end contract +// for the exception: a system admin can upload an SVG as the theme logo, the +// stored MIME type is image/svg+xml, and the public branding route serves it +// inline under a sandboxing CSP so the script-capable document cannot execute. +func TestUploadThemeLogo_AcceptsSVGAndServesSandboxed(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + permissionService, _, _ := createTestServices(t, *tdb) + seedNegativeTestUser(t, tdb.Database, 601) + admin := &models.User{ID: 601} + grantSystemAdmin(t, tdb, admin.ID) + + storage := blobstore.NewLocal(t.TempDir()) + handler := &AttachmentHandler{ + db: tdb.Database, + storage: storage, + permissionService: permissionService, + attachmentService: services.NewAttachmentServiceWithPermissions(tdb.Database, permissionService), + } + + req := svgMultipartUploadRequest(t, "theme_logo", "logo.svg", []byte(validThemeLogoSVG), nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + handler.Upload(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("theme logo SVG upload status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Success bool `json:"success"` + LogoURL string `json:"logo_url"` + AttachmentID int `json:"attachment_id"` + } + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode upload response: %v", err) + } + if !response.Success { + t.Fatalf("upload response success = false; body=%s", rec.Body.String()) + } + wantURL := "/api/portal-assets/" + itoa(response.AttachmentID) + if response.LogoURL != wantURL { + t.Fatalf("logo_url = %q, want %q", response.LogoURL, wantURL) + } + + var mimeType, entityType string + if err := tdb.Database.QueryRow(`SELECT mime_type, entity_type FROM attachments WHERE id = ?`, response.AttachmentID).Scan(&mimeType, &entityType); err != nil { + t.Fatalf("read attachment row: %v", err) + } + if mimeType != "image/svg+xml" { + t.Fatalf("stored mime_type = %q, want image/svg+xml", mimeType) + } + if entityType != "theme_logo" { + t.Fatalf("stored entity_type = %q, want theme_logo", entityType) + } + + portal := &PortalHandler{db: tdb.Database, storage: storage} + dlReq := httptest.NewRequest(http.MethodGet, wantURL, nil) + dlReq.SetPathValue("id", itoa(response.AttachmentID)) + dlRec := httptest.NewRecorder() + portal.DownloadPortalAttachment(dlRec, dlReq) + + if dlRec.Code != http.StatusOK { + t.Fatalf("theme logo SVG download status = %d, want 200; body=%s", dlRec.Code, dlRec.Body.String()) + } + if got := dlRec.Header().Get("Content-Type"); got != "image/svg+xml" { + t.Fatalf("served Content-Type = %q, want image/svg+xml", got) + } + if got := dlRec.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { + t.Fatalf("served Content-Disposition = %q, want an inline disposition", got) + } + csp := dlRec.Header().Get("Content-Security-Policy") + if !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") { + t.Fatalf("served CSP = %q, want a sandboxing default-src 'none' policy", csp) + } + if got := dlRec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("served X-Content-Type-Options = %q, want nosniff", got) + } + if !strings.Contains(dlRec.Body.String(), "not an svg`), nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + handler.Upload(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("non-SVG content with .svg extension status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "valid SVG") { + t.Fatalf("rejection body %q does not explain the SVG validation failure", rec.Body.String()) + } + var count int + if err := tdb.Database.QueryRow(`SELECT COUNT(*) FROM attachments WHERE entity_type = 'theme_logo'`).Scan(&count); err != nil { + t.Fatalf("count theme_logo attachments: %v", err) + } + if count != 0 { + t.Fatalf("rejected upload persisted %d theme_logo attachments, want 0", count) + } +} + +// TestUploadSVG_RejectedForNonThemeTargets pins the security boundary: the +// SVG exception is scoped to theme logos and every other image-only target +// still refuses the extension. +func TestUploadSVG_RejectedForNonThemeTargets(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + permissionService, _, _ := createTestServices(t, *tdb) + seedNegativeTestUser(t, tdb.Database, 603) + admin := &models.User{ID: 603} + grantSystemAdmin(t, tdb, admin.ID) + handler := newThemeLogoUploadHandler(t, tdb, permissionService) + + cases := []struct { + name string + entityType string + extra map[string]string + }{ + {name: "avatar", entityType: "avatar"}, + {name: "hub logo", entityType: "hub_logo"}, + {name: "portal logo", entityType: "portal_logo", extra: map[string]string{"entity_id": "1"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := svgMultipartUploadRequest(t, tc.entityType, "logo.svg", []byte(validThemeLogoSVG), tc.extra) + req = withUser(req, admin) + rec := httptest.NewRecorder() + handler.Upload(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s SVG upload status = %d, want 400; body=%s", tc.entityType, rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "not allowed for security reasons") { + t.Fatalf("%s rejection body %q does not name the extension block", tc.entityType, rec.Body.String()) + } + }) + } +} diff --git a/internal/handlers/audit_log_test.go b/internal/handlers/audit_log_test.go index ec6b379..1d69222 100644 --- a/internal/handlers/audit_log_test.go +++ b/internal/handlers/audit_log_test.go @@ -10,6 +10,7 @@ import ( "time" "windshift/internal/database" + "windshift/internal/logger" "windshift/internal/repository" ) @@ -23,7 +24,7 @@ func TestListAuditLogsRejectsInvalidFilters(t *testing.T) { for _, target := range tests { t.Run(target, func(t *testing.T) { - h := NewAuditLogHandler(nil) + h := NewAuditLogHandler(nil, nil) req := httptest.NewRequest(http.MethodGet, target, nil) rr := httptest.NewRecorder() @@ -67,7 +68,7 @@ func newAuditLogStreamTestHandler(t *testing.T) (*AuditLogHandler, database.Data t.Fatalf("create audit_logs: %v", err) } - return NewAuditLogHandler(repository.NewAuditLogRepository(db)), db + return NewAuditLogHandler(repository.NewAuditLogRepository(db), logger.NewAuditor(db)), db } func seedStreamRow(t *testing.T, db database.Database, action string) int { diff --git a/internal/handlers/auth_policy_login_test.go b/internal/handlers/auth_policy_login_test.go index eeaf2d4..d06d431 100644 --- a/internal/handlers/auth_policy_login_test.go +++ b/internal/handlers/auth_policy_login_test.go @@ -59,8 +59,7 @@ func TestPasswordLoginCreatesOnlyRestrictedSessionsUnderPasskeyPolicies(t *testi if err != nil { t.Fatalf("NewPermissionService: %v", err) } - rateLimiter := middleware.NewRateLimiter(100, 100, false, nil) - defer rateLimiter.Stop() + loginLockout := middleware.NewLoginLockout() sessionManager := auth.NewSessionManagerWithValidationCacheTTL(db, false, false, nil, "auth-policy-test-secret", "strict", auth.DefaultSessionValidationCacheTTL) policyHandler := NewAuthPolicyHandlerWithFallback(db, false, logger.NewAuditor(db)) handler := NewAuthHandler( @@ -68,7 +67,7 @@ func TestPasswordLoginCreatesOnlyRestrictedSessionsUnderPasskeyPolicies(t *testi repository.NewCredentialRepository(db), logger.NewAuditor(db), sessionManager, - rateLimiter, + loginLockout, permissionService, nil, utils.NewIPExtractor(false, nil), @@ -130,7 +129,7 @@ func TestPasswordLoginCreatesOnlyRestrictedSessionsUnderPasskeyPolicies(t *testi repository.NewCredentialRepository(db), logger.NewAuditor(db), sessionManager, - rateLimiter, + loginLockout, permissionService, nil, utils.NewIPExtractor(false, nil), @@ -176,8 +175,7 @@ func TestSSORequiredRejectsPasswordLoginWithoutFallback(t *testing.T) { if err != nil { t.Fatalf("NewPermissionService: %v", err) } - rateLimiter := middleware.NewRateLimiter(100, 100, false, nil) - defer rateLimiter.Stop() + loginLockout := middleware.NewLoginLockout() sessionManager := auth.NewSessionManagerWithValidationCacheTTL(db, false, false, nil, "auth-policy-sso-test-secret", "strict", auth.DefaultSessionValidationCacheTTL) policyHandler := NewAuthPolicyHandlerWithFallback(db, false, logger.NewAuditor(db)) if err := policyHandler.upsertSetting("auth_policy", string(AuthPolicySSOPrimary), "string", "test", "auth"); err != nil { @@ -188,7 +186,7 @@ func TestSSORequiredRejectsPasswordLoginWithoutFallback(t *testing.T) { repository.NewCredentialRepository(db), logger.NewAuditor(db), sessionManager, - rateLimiter, + loginLockout, permissionService, nil, utils.NewIPExtractor(false, nil), @@ -212,7 +210,7 @@ func TestSSORequiredRejectsPasswordLoginWithoutFallback(t *testing.T) { repository.NewCredentialRepository(db), logger.NewAuditor(db), sessionManager, - rateLimiter, + loginLockout, permissionService, nil, utils.NewIPExtractor(false, nil), diff --git a/internal/handlers/diagnostics_cache_memory_test.go b/internal/handlers/diagnostics_cache_memory_test.go index 493d79d..316d012 100644 --- a/internal/handlers/diagnostics_cache_memory_test.go +++ b/internal/handlers/diagnostics_cache_memory_test.go @@ -25,6 +25,16 @@ func TestDiagnosticsCacheMemoryReportsBudgetAndRegisteredCaches(t *testing.T) { t.Fatalf("read cache: %v", err) } + typed := cacheutil.NewTyped[*int]("diagnostics_typed_test", cacheutil.TypedCacheOptions{ + TTL: time.Minute, Shards: 1, + }) + t.Cleanup(func() { _ = typed.Close() }) + value := 7 + typed.Set(1, &value) + if _, ok := typed.Get(1); !ok { + t.Fatal("typed cache miss after set") + } + budget, err := config.ResolveMemoryBudget(512) if err != nil { t.Fatalf("resolve budget: %v", err) @@ -61,12 +71,19 @@ func TestDiagnosticsCacheMemoryReportsBudgetAndRegisteredCaches(t *testing.T) { t.Fatalf("invalid GC diagnostics: %+v", response) } found := false + foundTyped := false for _, snapshot := range response.Caches { - if snapshot.Name == "diagnostics_test" { + switch snapshot.Name { + case "diagnostics_test": found = snapshot.Entries == 1 && snapshot.Hits == 1 + case "diagnostics_typed_test": + foundTyped = snapshot.Entries == 1 && snapshot.Hits == 1 && snapshot.Misses == 0 } } if !found { t.Fatalf("diagnostics_test cache missing or incorrect: %+v", response.Caches) } + if !foundTyped { + t.Fatalf("diagnostics_typed_test cache missing or incorrect: %+v", response.Caches) + } } diff --git a/internal/handlers/general_settings_test.go b/internal/handlers/general_settings_test.go new file mode 100644 index 0000000..c5b4e5d --- /dev/null +++ b/internal/handlers/general_settings_test.go @@ -0,0 +1,148 @@ +//go:build test + +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +// newGeneralSettingsHandler wires the general-settings handler against a +// production-schema test database. The auditor is nil because these tests +// assert HTTP contracts, not audit rows. +func newGeneralSettingsHandler(t *testing.T) (*GeneralSettingsHandler, *testutils.TestDB) { + t.Helper() + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + handler := NewGeneralSettingsHandler( + repository.NewSystemSettingRepository(tdb.Database), + repository.NewThemeRepository(tdb.Database), + nil, + ) + return handler, tdb +} + +func TestGetBranding_DefaultsToProductName(t *testing.T) { + handler, _ := newGeneralSettingsHandler(t) + + rec := httptest.NewRecorder() + handler.GetBranding(rec, httptest.NewRequest(http.MethodGet, "/api/branding", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var branding BrandingResponse + if err := json.NewDecoder(rec.Body).Decode(&branding); err != nil { + t.Fatalf("decode branding: %v", err) + } + if branding.SiteTitle != DefaultSiteTitle { + t.Fatalf("site_title = %q, want %q", branding.SiteTitle, DefaultSiteTitle) + } + if branding.BrandLayout != models.BrandLayoutMarkText { + t.Fatalf("brand_layout = %q, want %q", branding.BrandLayout, models.BrandLayoutMarkText) + } +} + +func TestUpdateSettings_PersistsSiteTitleAndFeedsBranding(t *testing.T) { + handler, _ := newGeneralSettingsHandler(t) + + rec := httptest.NewRecorder() + handler.UpdateSettings(rec, httptest.NewRequest( + http.MethodPut, "/api/admin/general-settings", + bytes.NewReader([]byte(`{"site_title":"Acme Tracker"}`)), + )) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var updated GeneralSettings + if err := json.NewDecoder(rec.Body).Decode(&updated); err != nil { + t.Fatalf("decode settings: %v", err) + } + if updated.SiteTitle != "Acme Tracker" { + t.Fatalf("site_title = %q, want Acme Tracker", updated.SiteTitle) + } + + brandingRec := httptest.NewRecorder() + handler.GetBranding(brandingRec, httptest.NewRequest(http.MethodGet, "/api/branding", nil)) + var branding BrandingResponse + if err := json.NewDecoder(brandingRec.Body).Decode(&branding); err != nil { + t.Fatalf("decode branding: %v", err) + } + if branding.SiteTitle != "Acme Tracker" { + t.Fatalf("branding site_title = %q, want Acme Tracker", branding.SiteTitle) + } +} + +func TestUpdateSettings_BlankTitleFallsBackToProductName(t *testing.T) { + handler, _ := newGeneralSettingsHandler(t) + + rec := httptest.NewRecorder() + handler.UpdateSettings(rec, httptest.NewRequest( + http.MethodPut, "/api/admin/general-settings", + bytes.NewReader([]byte(`{"site_title":" "}`)), + )) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var updated GeneralSettings + if err := json.NewDecoder(rec.Body).Decode(&updated); err != nil { + t.Fatalf("decode settings: %v", err) + } + if updated.SiteTitle != DefaultSiteTitle { + t.Fatalf("site_title = %q, want %q", updated.SiteTitle, DefaultSiteTitle) + } +} + +func TestGetBranding_ReflectsActiveThemeBranding(t *testing.T) { + handler, tdb := newGeneralSettingsHandler(t) + + themeRepo := repository.NewThemeRepository(tdb.Database) + active, err := themeRepo.GetActive() + if err != nil { + t.Fatalf("get active theme: %v", err) + } + if err := themeRepo.Update(active.ID, models.ThemeUpdateRequest{ + Name: active.Name, + Description: active.Description, + NavBackgroundColorLight: active.NavBackgroundColorLight, + NavTextColorLight: active.NavTextColorLight, + NavBackgroundColorDark: active.NavBackgroundColorDark, + NavTextColorDark: active.NavTextColorDark, + LogoURL: "/api/portal-assets/1", + LogoURLDark: "/api/portal-assets/2", + LogoFullURL: "/api/portal-assets/3", + LogoFullURLDark: "/api/portal-assets/4", + BrandLayout: models.BrandLayoutFullLogo, + IsActive: true, + }, time.Now()); err != nil { + t.Fatalf("update active theme: %v", err) + } + + rec := httptest.NewRecorder() + handler.GetBranding(rec, httptest.NewRequest(http.MethodGet, "/api/branding", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var branding BrandingResponse + if err := json.NewDecoder(rec.Body).Decode(&branding); err != nil { + t.Fatalf("decode branding: %v", err) + } + if branding.BrandLayout != models.BrandLayoutFullLogo { + t.Fatalf("brand_layout = %q, want %q", branding.BrandLayout, models.BrandLayoutFullLogo) + } + if branding.LogoURL != "/api/portal-assets/1" || branding.LogoURLDark != "/api/portal-assets/2" { + t.Fatalf("mark logos = %q / %q", branding.LogoURL, branding.LogoURLDark) + } + if branding.LogoFullURL != "/api/portal-assets/3" || branding.LogoFullURLDark != "/api/portal-assets/4" { + t.Fatalf("full logos = %q / %q", branding.LogoFullURL, branding.LogoFullURLDark) + } +} diff --git a/internal/handlers/integration_oauth_binding_test.go b/internal/handlers/integration_oauth_binding_test.go index 48d8d8b..234fa81 100644 --- a/internal/handlers/integration_oauth_binding_test.go +++ b/internal/handlers/integration_oauth_binding_test.go @@ -39,7 +39,7 @@ func TestIntegrationOAuthCallbackRejectsOtherAccounts(t *testing.T) { } } - handler := NewIntegrationOAuthHandler(db, nil, "http://test.local") + handler := NewIntegrationOAuthHandler(db, nil, "http://test.local", nil) callback := func(t *testing.T, state string, user *models.User) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/integrations/oauth/todoist/callback?code=xyz&state="+state, nil) diff --git a/internal/handlers/jira_analyzer_identity_test.go b/internal/handlers/jira_analyzer_identity_test.go new file mode 100644 index 0000000..db8e98b --- /dev/null +++ b/internal/handlers/jira_analyzer_identity_test.go @@ -0,0 +1,41 @@ +//go:build test + +package handlers + +import ( + "testing" + + "windshift/internal/jira" +) + +// TestCollectUserSummaryNormalizesDataCenterNameKeyIdentities is the WI-2077 +// regression: Data Center users carry name/key rather than accountId, and the +// analysis preview must include them with the same normalized identity the +// execution path uses. +func TestCollectUserSummaryNormalizesDataCenterNameKeyIdentities(t *testing.T) { + t.Parallel() + + userMap := map[string]JiraUserSummary{} + collectUserSummary(userMap, &jira.JiraUser{Name: "ada", DisplayName: "Ada Lovelace", EmailAddress: "ada@example.invalid"}) + collectUserSummary(userMap, &jira.JiraUser{Key: "grace-key", DisplayName: "Grace Hopper"}) + // Duplicate sighting of the same name must not add a second entry. + collectUserSummary(userMap, &jira.JiraUser{Name: "ada", DisplayName: "Ada Lovelace"}) + // Cloud accountId keeps working. + collectUserSummary(userMap, &jira.JiraUser{AccountID: "acc-cloud", DisplayName: "Cloud User"}) + // Anonymous users are skipped. + collectUserSummary(userMap, &jira.JiraUser{DisplayName: "No Identifier"}) + collectUserSummary(userMap, nil) + + if len(userMap) != 3 { + t.Fatalf("user map = %#v, want 3 normalized identities", userMap) + } + if userMap["ada"].DisplayName != "Ada Lovelace" || userMap["ada"].Email != "ada@example.invalid" { + t.Fatalf("name identity = %#v, want Ada with email", userMap["ada"]) + } + if userMap["grace-key"].DisplayName != "Grace Hopper" { + t.Fatalf("key identity = %#v, want Grace", userMap["grace-key"]) + } + if _, ok := userMap["acc-cloud"]; !ok { + t.Fatalf("cloud identity missing from %#v", userMap) + } +} diff --git a/internal/handlers/jira_asset_page_exhaustion_test.go b/internal/handlers/jira_asset_page_exhaustion_test.go new file mode 100644 index 0000000..895b6e0 --- /dev/null +++ b/internal/handlers/jira_asset_page_exhaustion_test.go @@ -0,0 +1,84 @@ +//go:build test + +package handlers + +import ( + "testing" + + "windshift/internal/jira" +) + +// TestJiraNextAssetObjectOffsetAdvancesByConsumedRows is the WI-2070 +// regression: a server-capped short non-final page must advance the walk by the +// rows it actually returned instead of by the requested page size. +func TestJiraNextAssetObjectOffsetAdvancesByConsumedRows(t *testing.T) { + t.Parallel() + + entries := func(n int) []jira.AssetObject { + out := make([]jira.AssetObject, n) + return out + } + + tests := []struct { + name string + startAt int + result *jira.ObjectSearchResult + wantNext int + wantAdvance bool + }{ + { + name: "short first page advances by returned rows", + startAt: 0, + result: &jira.ObjectSearchResult{ + ObjectEntries: entries(25), StartIndex: 0, ToIndex: 25, + }, + wantNext: 25, + wantAdvance: true, + }, + { + name: "short later page advances by returned rows", + startAt: 25, + result: &jira.ObjectSearchResult{ + ObjectEntries: entries(25), StartIndex: 25, ToIndex: 50, + }, + wantNext: 50, + wantAdvance: true, + }, + { + name: "missing response start falls back to requested offset", + startAt: 25, + result: &jira.ObjectSearchResult{ + ObjectEntries: entries(25), StartIndex: 0, ToIndex: 0, + }, + wantNext: 50, + wantAdvance: true, + }, + { + name: "non-advancing page is rejected", + startAt: 25, + result: &jira.ObjectSearchResult{ + ObjectEntries: entries(0), StartIndex: 25, ToIndex: 25, + }, + wantAdvance: false, + }, + { + name: "nil result is rejected", + startAt: 0, + result: nil, + wantAdvance: false, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + next, advanced := jiraNextAssetObjectOffset(test.startAt, test.result) + if advanced != test.wantAdvance { + t.Fatalf("advanced = %t, want %t", advanced, test.wantAdvance) + } + if advanced && next != test.wantNext { + t.Fatalf("next = %d, want %d", next, test.wantNext) + } + }) + } +} diff --git a/internal/handlers/jira_asset_typed_values_test.go b/internal/handlers/jira_asset_typed_values_test.go new file mode 100644 index 0000000..d2ce5fe --- /dev/null +++ b/internal/handlers/jira_asset_typed_values_test.go @@ -0,0 +1,101 @@ +//go:build test + +package handlers + +import ( + "testing" + + "windshift/internal/jira" + "windshift/internal/models" +) + +// TestJiraAssetAttributeNativeValueUsesCanonicalValues is the WI-2069 +// regression: native asset fields must use the canonical raw value instead of +// locale-formatted display text. +func TestJiraAssetAttributeNativeValueUsesCanonicalValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + attr jira.AssetObjectAttributeValue + fieldType string + want any + wantOK bool + }{ + { + name: "number prefers canonical value over localized display", + attr: jira.AssetObjectAttributeValue{ObjectAttributeValues: []jira.AssetAttributeValue{ + {Value: 1234.5, DisplayValue: "1,234.5"}, + }}, + fieldType: "number", + want: 1234.5, + wantOK: true, + }, + { + name: "date normalizes the canonical timestamp to a calendar date", + attr: jira.AssetObjectAttributeValue{ObjectAttributeValues: []jira.AssetAttributeValue{ + {Value: "2023-05-01T00:00:00.000Z", DisplayValue: "May 1, 2023"}, + }}, + fieldType: "date", + want: "2023-05-01", + wantOK: true, + }, + { + name: "multiple scalar values keep canonical text instead of pretending", + attr: jira.AssetObjectAttributeValue{ObjectAttributeValues: []jira.AssetAttributeValue{ + {Value: "alpha"}, + {Value: "beta"}, + }}, + fieldType: "number", + want: "alpha\nbeta", + wantOK: true, + }, + { + name: "invalid number is not stored as a fake scalar", + attr: jira.AssetObjectAttributeValue{ObjectAttributeValues: []jira.AssetAttributeValue{ + {DisplayValue: "not a number"}, + }}, + fieldType: "number", + wantOK: false, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got, ok := jiraAssetAttributeNativeValue(test.attr, test.fieldType) + if ok != test.wantOK { + t.Fatalf("ok = %t, want %t (value %#v)", ok, test.wantOK, got) + } + if ok && got != test.want { + t.Fatalf("value = %#v, want %#v", got, test.want) + } + }) + } +} + +// TestJiraAssetAttributeFieldTypeDistinguishesDateAndTime pins the DefaultType +// dispatch: Date uses the native date field, Time is preserved as text, and +// DateTime uses the native date with timestamp provenance. +func TestJiraAssetAttributeFieldTypeDistinguishesDateAndTime(t *testing.T) { + t.Parallel() + + tests := []struct { + defaultTypeID int + want string + }{ + {defaultTypeID: 1, want: "number"}, + {defaultTypeID: 3, want: "number"}, + {defaultTypeID: 2, want: models.CustomFieldTypeBoolean}, + {defaultTypeID: 4, want: "date"}, + {defaultTypeID: 5, want: "textarea"}, + {defaultTypeID: 6, want: "date"}, + {defaultTypeID: 99, want: "textarea"}, + } + for _, test := range tests { + got := jiraAssetAttributeFieldType(jira.AssetObjectAttribute{Type: 0, DefaultTypeID: test.defaultTypeID}) + if got != test.want { + t.Fatalf("DefaultTypeID %d -> %q, want %q", test.defaultTypeID, got, test.want) + } + } +} diff --git a/internal/handlers/jira_assets_unavailable_test.go b/internal/handlers/jira_assets_unavailable_test.go new file mode 100644 index 0000000..b416cf8 --- /dev/null +++ b/internal/handlers/jira_assets_unavailable_test.go @@ -0,0 +1,53 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +type unavailableAssetsClient struct { + jira.Client +} + +func (c *unavailableAssetsClient) ListObjectSchemas(context.Context) ([]jira.AssetObjectSchema, error) { + return nil, jira.ErrAssetsNotAvailable +} + +// TestImportJiraAssetsUnavailableRecordsFindingWithoutCreatingSets is the +// WI-2078 regression: an unsupported Assets capability must not create empty +// native asset sets, and must record a durable finding. +func TestImportJiraAssetsUnavailableRecordsFindingWithoutCreatingSets(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + h := NewJiraImportHandler(db, "jira-assets-unavailable-secret", "", blobstore.Disabled()) + + h.importJiraAssets(context.Background(), "job-jsm", &unavailableAssetsClient{}, 0) + + var setCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM asset_management_sets`).Scan(&setCount); err != nil { + t.Fatalf("count asset sets: %v", err) + } + if setCount != 0 { + t.Fatalf("asset set count = %d, want 0 when Assets is unavailable", setCount) + } + + var metadataJSON string + if err := db.QueryRow(` + SELECT metadata_json FROM jira_import_id_mappings + WHERE job_id = 'job-jsm' AND entity_type = 'fidelity_finding' AND jira_id = 'assets-capability' + `).Scan(&metadataJSON); err != nil { + t.Fatalf("load Assets fidelity finding: %v", err) + } + var metadata map[string]any + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + t.Fatalf("decode Assets fidelity finding: %v", err) + } + if metadata["code"] != "jira_assets_unavailable" || metadata["severity"] != "warning" { + t.Fatalf("Assets finding metadata = %#v, want jira_assets_unavailable/warning", metadata) + } +} diff --git a/internal/handlers/jira_attachment_failure_reporting_test.go b/internal/handlers/jira_attachment_failure_reporting_test.go new file mode 100644 index 0000000..8ca1452 --- /dev/null +++ b/internal/handlers/jira_attachment_failure_reporting_test.go @@ -0,0 +1,125 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +type failingAttachmentClient struct { + jira.Client + err error +} + +func (c *failingAttachmentClient) DownloadAttachment(context.Context, string, string) (io.ReadCloser, string, error) { + return nil, "", c.err +} + +func newJiraAttachmentReportingFixture(t *testing.T, storage *blobstore.Store, root string) (*JiraImportHandler, int, int, int) { + t.Helper() + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-attachment-failure-secret", "", storage) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + if storage.Enabled() { + if _, err := db.ExecWrite(`INSERT INTO attachment_settings (attachment_path, enabled) VALUES (?, true)`, root); err != nil { + t.Fatalf("configure attachments: %v", err) + } + } + return handler, workspaceID, itemTypeID, statusID +} + +// TestJiraAttachmentFailuresAreCountedAndReported is the WI-2061 regression: +// a failed attachment download must increment FailedAttachments, push the job +// to completed_with_errors, and persist a durable fidelity finding instead of +// looking like a clean import. +func TestJiraAttachmentFailuresAreCountedAndReported(t *testing.T) { + root := t.TempDir() + handler, workspaceID, itemTypeID, statusID := newJiraAttachmentReportingFixture(t, blobstore.NewLocal(root), root) + db := handler.db + + const attachmentCount = 2 + issue := attachmentConcurrencyIssue(attachmentCount) + client := &failingAttachmentClient{err: errors.New("jira attachment denied with 403")} + im := attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID, client) + + if err := handler.importIssue(t.Context(), im, &issue); err != nil { + t.Fatalf("import issue: %v", err) + } + + if im.progress.TotalAttachments != attachmentCount { + t.Fatalf("TotalAttachments = %d, want %d", im.progress.TotalAttachments, attachmentCount) + } + if im.progress.ImportedAttachments != 0 { + t.Fatalf("ImportedAttachments = %d, want 0", im.progress.ImportedAttachments) + } + if im.progress.FailedAttachments != attachmentCount { + t.Fatalf("FailedAttachments = %d, want %d", im.progress.FailedAttachments, attachmentCount) + } + + // The issue itself imported, so the job must report completed_with_errors + // rather than a clean completed after the attachment failures. + im.progress.ImportedIssues = 1 + status, phase, _ := jiraImportTerminalOutcome(im.progress) + if status != "completed_with_errors" || phase != "completed_with_errors" { + t.Fatalf("terminal outcome = %s/%s, want completed_with_errors/completed_with_errors", status, phase) + } + + claimJiraImportJobForTest(t, handler, db, "job-jsm") + handler.persistJiraImportResult("job-jsm", im.progress) + + var resultJSON string + if err := db.QueryRow(`SELECT result_json FROM jira_import_jobs WHERE id = 'job-jsm'`).Scan(&resultJSON); err != nil { + t.Fatalf("load persisted import result: %v", err) + } + var result struct { + Findings []jiraImportFidelityFinding `json:"fidelity_findings"` + } + if err := json.Unmarshal([]byte(resultJSON), &result); err != nil { + t.Fatalf("decode persisted import result: %v", err) + } + var finding *jiraImportFidelityFinding + for i := range result.Findings { + if result.Findings[i].Code == "jira_attachments_failed" { + finding = &result.Findings[i] + break + } + } + if finding == nil { + t.Fatalf("persisted findings = %#v, want jira_attachments_failed", result.Findings) + } + if finding.Severity != "error" || finding.Count != attachmentCount { + t.Fatalf("attachment finding = %#v, want error severity and count %d", *finding, attachmentCount) + } +} + +// TestJiraAttachmentStorageDisabledIsSkippedNotFailed proves intentionally +// disabled storage is distinguishable from a failed attempted download. +func TestJiraAttachmentStorageDisabledIsSkippedNotFailed(t *testing.T) { + handler, workspaceID, itemTypeID, statusID := newJiraAttachmentReportingFixture(t, blobstore.Disabled(), "") + + const attachmentCount = 3 + issue := attachmentConcurrencyIssue(attachmentCount) + im := attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID, &fakeJiraAttachmentImportClient{}) + + if err := handler.importIssue(t.Context(), im, &issue); err != nil { + t.Fatalf("import issue: %v", err) + } + + if im.progress.SkippedAttachments != attachmentCount { + t.Fatalf("SkippedAttachments = %d, want %d", im.progress.SkippedAttachments, attachmentCount) + } + if im.progress.FailedAttachments != 0 { + t.Fatalf("FailedAttachments = %d, want 0 for disabled storage", im.progress.FailedAttachments) + } + status, _, _ := jiraImportTerminalOutcome(im.progress) + if status != "completed" { + t.Fatalf("terminal outcome = %s, want completed when storage is intentionally disabled", status) + } +} diff --git a/internal/handlers/jira_bulk_fetch_errors_test.go b/internal/handlers/jira_bulk_fetch_errors_test.go index e7e4471..76181d5 100644 --- a/internal/handlers/jira_bulk_fetch_errors_test.go +++ b/internal/handlers/jira_bulk_fetch_errors_test.go @@ -8,6 +8,76 @@ import ( "windshift/internal/jira" ) +// TestRecordJiraBulkFetchResultsCorrelatesPublishedIssueError is the WI-2065 +// regression: the published Cloud IssueError shape is {id, errorMessage} with +// the numeric issue id, not the key. The importer must retain the source id and +// message while counting the requested failed issue exactly once. +func TestRecordJiraBulkFetchResultsCorrelatesPublishedIssueError(t *testing.T) { + t.Parallel() + + plan := &xrayImportPlan{ + keysByProject: map[string]map[string]struct{}{ + "APP": {"APP-2": {}}, + }, + } + + tests := []struct { + name string + requested []string + result *jira.BulkFetchResponse + keysByID map[string]string + wantIssues int + wantTests int + }{ + { + name: "numeric id resolves to the requested key", + requested: []string{"APP-1", "APP-2"}, + result: &jira.BulkFetchResponse{ + Issues: []jira.JiraIssue{{ID: "10001", Key: "APP-1"}}, + IssueErrors: []jira.BulkFetchError{{ID: "10002", ErrorMessage: "Issue does not exist or you do not have permission to see it."}}, + }, + keysByID: map[string]string{"10001": "APP-1", "10002": "APP-2"}, + wantIssues: 0, + wantTests: 1, + }, + { + name: "unexplained omission still counts once", + requested: []string{"APP-1", "APP-2", "APP-3"}, + result: &jira.BulkFetchResponse{ + Issues: []jira.JiraIssue{{ID: "10001", Key: "APP-1"}}, + IssueErrors: []jira.BulkFetchError{{ID: "10002", ErrorMessage: "gone"}}, + }, + keysByID: map[string]string{"10001": "APP-1", "10002": "APP-2"}, + wantIssues: 1, + wantTests: 1, + }, + { + name: "uncorrelated id is paired with the missing key once", + requested: []string{"APP-1"}, + result: &jira.BulkFetchResponse{ + IssueErrors: []jira.BulkFetchError{{ID: "99999", ErrorMessage: "deleted"}}, + }, + keysByID: map[string]string{"10001": "APP-1"}, + wantIssues: 1, + wantTests: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + progress := &ImportProgress{} + recordJiraBulkFetchResults("APP", tt.requested, tt.result, tt.keysByID, plan, progress) + if progress.FailedIssues != tt.wantIssues { + t.Errorf("failed issues = %d, want %d", progress.FailedIssues, tt.wantIssues) + } + if progress.FailedTests != tt.wantTests { + t.Errorf("failed tests = %d, want %d", progress.FailedTests, tt.wantTests) + } + }) + } +} + func TestRecordJiraBulkFetchResultsAccountsForEveryRequestedKey(t *testing.T) { t.Parallel() @@ -18,11 +88,11 @@ func TestRecordJiraBulkFetchResultsAccountsForEveryRequestedKey(t *testing.T) { } tests := []struct { - name string - requested []string - result *jira.BulkFetchResponse - wantIssues int - wantTests int + name string + requested []string + result *jira.BulkFetchResponse + wantIssues int + wantTests int }{ { name: "issue errors count as failures", @@ -61,9 +131,9 @@ func TestRecordJiraBulkFetchResultsAccountsForEveryRequestedKey(t *testing.T) { wantTests: 0, }, { - name: "nil response fails the whole batch", - requested: []string{"APP-1", "APP-2"}, - result: nil, + name: "nil response fails the whole batch", + requested: []string{"APP-1", "APP-2"}, + result: nil, wantIssues: 1, wantTests: 1, }, @@ -74,7 +144,7 @@ func TestRecordJiraBulkFetchResultsAccountsForEveryRequestedKey(t *testing.T) { t.Parallel() progress := &ImportProgress{} - recordJiraBulkFetchResults("APP", tt.requested, tt.result, plan, progress) + recordJiraBulkFetchResults("APP", tt.requested, tt.result, nil, plan, progress) if progress.FailedIssues != tt.wantIssues { t.Errorf("failed issues = %d, want %d", progress.FailedIssues, tt.wantIssues) } diff --git a/internal/handlers/jira_custom_fields_live_test.go b/internal/handlers/jira_custom_fields_live_test.go index 8890693..a4f8e35 100644 --- a/internal/handlers/jira_custom_fields_live_test.go +++ b/internal/handlers/jira_custom_fields_live_test.go @@ -109,7 +109,7 @@ func TestLiveJiraCustomFieldFidelity(t *testing.T) { } handler := NewJiraImportHandler(nil, "live-jira-field-read-only-secret", "", blobstore.Disabled()) - issueKeysByProject, _, _, choiceLabels := handler.preflightJiraCustomFields( + issueKeysByProject, _, _, _, choiceLabels := handler.preflightJiraCustomFields( ctx, "", client, diff --git a/internal/handlers/jira_enhanced_search_sampling_test.go b/internal/handlers/jira_enhanced_search_sampling_test.go index e1fbfd3..9d998c1 100644 --- a/internal/handlers/jira_enhanced_search_sampling_test.go +++ b/internal/handlers/jira_enhanced_search_sampling_test.go @@ -61,7 +61,10 @@ func TestSampleIssuesFetchesAllPagesThroughEnhancedJQLSearch(t *testing.T) { }, } - got := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 200) + got, err := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 200) + if err != nil { + t.Fatalf("sampleIssues error = %v", err) + } if client.legacyCalls != 0 { t.Fatalf("sampleIssues called the removed legacy search %d times", client.legacyCalls) @@ -105,7 +108,10 @@ func TestSampleIssuesStopsAtSampleLimitAcrossPages(t *testing.T) { }, } - got := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 150) + got, err := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 150) + if err != nil { + t.Fatalf("sampleIssues error = %v", err) + } if client.legacyCalls != 0 { t.Fatalf("sampleIssues called the removed legacy search %d times", client.legacyCalls) @@ -128,7 +134,10 @@ func TestSampleIssuesReturnsEmptyWhenEnhancedSearchFails(t *testing.T) { searchErr: errors.New("jira API error: status 400 - invalid JQL"), } - got := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 200) + got, err := (&JiraImportHandler{}).sampleIssues(context.Background(), client, "CT", false, 200) + if err == nil { + t.Fatal("sampleIssues error = nil, want the search failure propagated") + } if len(got) != 0 { t.Fatalf("sampleIssues returned %d issues on search failure, want 0", len(got)) diff --git a/internal/handlers/jira_import_assets_test.go b/internal/handlers/jira_import_assets_test.go index 949a189..d368740 100644 --- a/internal/handlers/jira_import_assets_test.go +++ b/internal/handlers/jira_import_assets_test.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" "reflect" + "strconv" "testing" "windshift/internal/blobstore" @@ -28,6 +29,18 @@ func (f *fakeJiraAssetFieldClient) GetAllIssueKeys(_ context.Context, jql string return nil, nil } +func (f *fakeJiraAssetFieldClient) GetAllIssueRefs(ctx context.Context, jql string) ([]jira.JiraIssueRef, error) { + keys, err := f.GetAllIssueKeys(ctx, jql) + if err != nil { + return nil, err + } + refs := make([]jira.JiraIssueRef, 0, len(keys)) + for index, key := range keys { + refs = append(refs, jira.JiraIssueRef{ID: strconv.Itoa(10000 + index), Key: key}) + } + return refs, nil +} + func (f *fakeJiraAssetFieldClient) GetProject(_ context.Context, projectKey string) (*jira.JiraProject, error) { return &jira.JiraProject{ID: projectKey + "-id", Key: projectKey}, nil } @@ -177,7 +190,7 @@ func TestPreflightJiraAssetFieldsResolvesByFieldAcrossProjects(t *testing.T) { } h := NewJiraImportHandler(db, "jira-asset-field-preflight-secret", "", blobstore.Disabled()) - keys, got, applicable, choiceLabels := h.preflightJiraCustomFields( + keys, _, got, applicable, choiceLabels := h.preflightJiraCustomFields( t.Context(), "job-preflight", client, diff --git a/internal/handlers/jira_import_attachment_concurrency_test.go b/internal/handlers/jira_import_attachment_concurrency_test.go new file mode 100644 index 0000000..5dcd364 --- /dev/null +++ b/internal/handlers/jira_import_attachment_concurrency_test.go @@ -0,0 +1,230 @@ +//go:build test + +package handlers + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +// blockingAttachmentClient holds each download until `target` downloads have +// started, so a correctly concurrent importer reaches that concurrency to make +// progress. A timeout on the import context ends the wait instead of hanging. +type blockingAttachmentClient struct { + jira.Client + target int + + mu sync.Mutex + inFlight int + maxInFlight int + started int + release chan struct{} + closeOnce sync.Once +} + +func newBlockingAttachmentClient(target int) *blockingAttachmentClient { + return &blockingAttachmentClient{target: target, release: make(chan struct{})} +} + +func (c *blockingAttachmentClient) DownloadAttachment(ctx context.Context, _, _ string) (io.ReadCloser, string, error) { + c.mu.Lock() + c.inFlight++ + c.started++ + if c.inFlight > c.maxInFlight { + c.maxInFlight = c.inFlight + } + if c.started >= c.target { + c.closeOnce.Do(func() { close(c.release) }) + } + c.mu.Unlock() + + select { + case <-c.release: + case <-ctx.Done(): + } + + c.mu.Lock() + c.inFlight-- + c.mu.Unlock() + + return io.NopCloser(strings.NewReader("attachment evidence\n")), "text/plain", nil +} + +func (c *blockingAttachmentClient) peakConcurrency() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxInFlight +} + +func attachmentConcurrencyIssue(count int) jira.JiraIssue { + attachments := make([]jira.JiraAttachment, 0, count) + for i := 0; i < count; i++ { + attachments = append(attachments, jira.JiraAttachment{ + ID: fmt.Sprintf("attachment-%d", i), + Filename: fmt.Sprintf("evidence-%d.txt", i), + Content: fmt.Sprintf("https://example.invalid/attachment-%d", i), + }) + } + return jira.JiraIssue{ + ID: "10010", + Key: "APP-10", + Fields: jira.JiraIssueFields{ + Summary: "Many attachments", + IssueType: &jira.JiraIssueType{ID: "type"}, + Status: &jira.JiraStatus{ID: "status"}, + Attachment: attachments, + }, + } +} + +func attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID int, client jira.Client) *jiraImportContext { + return &jiraImportContext{ + jobID: "job-jsm", + progress: &ImportProgress{}, + workspaceID: workspaceID, + statusMap: map[string]int{"status": statusID}, + itemTypeMap: map[string]int{"type": itemTypeID}, + userMap: map[string]int{}, + usernameMap: map[string]string{}, + portalCustomerMap: map[string]int{}, + versionMap: map[string]int{}, + iterationMap: map[string]int{}, + customFieldIDMap: map[string]int{}, + choiceOptionIDs: map[string]map[string]int{}, + client: client, + } +} + +// TestJiraAttachmentImportDownloadsConcurrently is the WI-1982 contract: an +// issue's attachment downloads run in parallel up to the configured limit. +func TestJiraAttachmentImportDownloadsConcurrently(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + root := t.TempDir() + handler := NewJiraImportHandler(db, "jira-attachment-concurrency-secret", "", blobstore.NewLocal(root)). + WithImportConcurrency(4) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + if _, err := db.ExecWrite(`INSERT INTO attachment_settings (attachment_path, enabled) VALUES (?, true)`, root); err != nil { + t.Fatalf("configure attachments: %v", err) + } + + const attachmentCount = 6 + client := newBlockingAttachmentClient(3) + issue := attachmentConcurrencyIssue(attachmentCount) + im := attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID, client) + + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + if err := handler.importIssue(ctx, im, &issue); err != nil { + t.Fatalf("import issue: %v", err) + } + + var imported int + if err := db.QueryRow(` + SELECT COUNT(*) FROM attachments a JOIN items i ON a.item_id = i.id WHERE i.workspace_id = ? + `, workspaceID).Scan(&imported); err != nil { + t.Fatalf("count attachments: %v", err) + } + if imported != attachmentCount { + t.Fatalf("imported attachments = %d, want %d", imported, attachmentCount) + } + if peak := client.peakConcurrency(); peak < 3 { + t.Fatalf("peak concurrent downloads = %d, want at least 3", peak) + } +} + +// TestJiraAttachmentImportParallelMatchesSequential proves the concurrent +// download path produces the same persisted attachments as the sequential one. +func TestJiraAttachmentImportParallelMatchesSequential(t *testing.T) { + const attachmentCount = 5 + + run := func(t *testing.T, concurrency int) []string { + t.Helper() + db := newJiraServiceManagementTestDB(t) + root := t.TempDir() + handler := NewJiraImportHandler(db, "jira-attachment-parity-secret", "", blobstore.NewLocal(root)). + WithImportConcurrency(concurrency) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + if _, err := db.ExecWrite(`INSERT INTO attachment_settings (attachment_path, enabled) VALUES (?, true)`, root); err != nil { + t.Fatalf("configure attachments: %v", err) + } + + issue := attachmentConcurrencyIssue(attachmentCount) + im := attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID, &fakeJiraAttachmentImportClient{}) + if err := handler.importIssue(t.Context(), im, &issue); err != nil { + t.Fatalf("import issue: %v", err) + } + + rows, err := db.Query(` + SELECT a.original_filename + FROM attachments a JOIN items i ON a.item_id = i.id + WHERE i.workspace_id = ? + ORDER BY a.original_filename + `, workspaceID) + if err != nil { + t.Fatalf("list attachments: %v", err) + } + defer func() { _ = rows.Close() }() + + var filenames []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan attachment: %v", err) + } + filenames = append(filenames, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate attachments: %v", err) + } + return filenames + } + + sequential := run(t, 1) + parallel := run(t, 4) + if len(sequential) != attachmentCount { + t.Fatalf("sequential imported %d attachments, want %d", len(sequential), attachmentCount) + } + if strings.Join(sequential, ",") != strings.Join(parallel, ",") { + t.Fatalf("parallel attachments = %v, want the sequential set %v", parallel, sequential) + } +} + +// TestJiraAttachmentImportSkipsRecordsWhenContextCancelled is the regression +// guard for a cancelled import recording attachments with an empty file path. +// The download pool stops launching work once the context is done; those +// entries must be skipped rather than written as records that point at nothing. +func TestJiraAttachmentImportSkipsRecordsWhenContextCancelled(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + root := t.TempDir() + handler := NewJiraImportHandler(db, "jira-attachment-cancel-secret", "", blobstore.NewLocal(root)). + WithImportConcurrency(4) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + if _, err := db.ExecWrite(`INSERT INTO attachment_settings (attachment_path, enabled) VALUES (?, true)`, root); err != nil { + t.Fatalf("configure attachments: %v", err) + } + + issue := attachmentConcurrencyIssue(4) + im := attachmentConcurrencyImportContext(workspaceID, itemTypeID, statusID, &fakeJiraAttachmentImportClient{}) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + // Later subresources may fail on the cancelled context; only the attachment + // records are under test. + _ = handler.importIssue(ctx, im, &issue) + + var broken int + if err := db.QueryRow(`SELECT COUNT(*) FROM attachments WHERE COALESCE(file_path, '') = ''`).Scan(&broken); err != nil { + t.Fatalf("count attachments: %v", err) + } + if broken != 0 { + t.Fatalf("cancelled import recorded %d attachment(s) with an empty file path", broken) + } +} diff --git a/internal/handlers/jira_import_completion_test.go b/internal/handlers/jira_import_completion_test.go index 359ba41..923900a 100644 --- a/internal/handlers/jira_import_completion_test.go +++ b/internal/handlers/jira_import_completion_test.go @@ -3,8 +3,10 @@ package handlers import ( + "context" "strings" "testing" + "time" ) func TestJiraImportTerminalOutcome(t *testing.T) { @@ -75,3 +77,94 @@ func TestJiraImportTerminalOutcome(t *testing.T) { }) } } + +func TestJiraImportContextRespectsConfiguredLimit(t *testing.T) { + t.Parallel() + + unlimited, cancelUnlimited := (&JiraImportHandler{}).jiraImportContext(context.Background()) + defer cancelUnlimited() + if _, hasDeadline := unlimited.Deadline(); hasDeadline { + t.Fatal("default import context has a deadline, want none") + } + + limited, cancelLimited := (&JiraImportHandler{}). + WithImportMaxDuration(2 * time.Hour). + jiraImportContext(context.Background()) + defer cancelLimited() + deadline, hasDeadline := limited.Deadline() + if !hasDeadline { + t.Fatal("configured import limit has no deadline") + } + if remaining := time.Until(deadline); remaining <= 0 || remaining > 2*time.Hour { + t.Fatalf("deadline remaining = %v, want within (0, 2h]", remaining) + } +} + +func TestJiraImportTimedOutOutcome(t *testing.T) { + t.Parallel() + tests := []struct { + name string + contextKind string + progress ImportProgress + wantTimedOut bool + wantStatus string + wantPhase string + wantDetails []string + }{ + { + name: "a live context is not a timeout", + contextKind: "live", + progress: ImportProgress{ImportedIssues: 1, TotalIssues: 2}, + }, + { + name: "cancellation is not reported as a time limit", + contextKind: "cancelled", + progress: ImportProgress{ImportedIssues: 1, TotalIssues: 2}, + }, + { + name: "deadline reports a partial stop with counts and resume guidance", + contextKind: "deadline", + progress: ImportProgress{ImportedIssues: 1800, FailedIssues: 40, TotalIssues: 2535, ImportedTests: 5, TotalTests: 5}, + wantTimedOut: true, + wantStatus: "timed_out", + wantPhase: "timed_out", + wantDetails: []string{"1805 of 2540", "same workspace", "Jira ID"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + switch test.contextKind { + case "deadline": + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer cancel() + case "cancelled": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + + status, phase, message, timedOut := jiraImportTimedOutOutcome(ctx, &test.progress) + if timedOut != test.wantTimedOut { + t.Fatalf("timedOut = %t, want %t", timedOut, test.wantTimedOut) + } + if !timedOut { + if status != "" || phase != "" || message != "" { + t.Fatalf("non-timeout outcome = (%q, %q, %q), want empty", status, phase, message) + } + return + } + if status != test.wantStatus || phase != test.wantPhase { + t.Fatalf("outcome = (%q, %q), want (%q, %q)", status, phase, test.wantStatus, test.wantPhase) + } + for _, detail := range test.wantDetails { + if !strings.Contains(message, detail) { + t.Fatalf("message %q does not contain %q", message, detail) + } + } + }) + } +} diff --git a/internal/handlers/jira_import_parallel_test.go b/internal/handlers/jira_import_parallel_test.go new file mode 100644 index 0000000..adea9d1 --- /dev/null +++ b/internal/handlers/jira_import_parallel_test.go @@ -0,0 +1,172 @@ +//go:build test + +package handlers + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +// TestJiraParallelRunsEveryIndexWithinWorkerLimit is the WI-1982 helper +// contract: every index runs exactly once, at most `workers` run at a time, +// and the pool actually overlaps work. +func TestJiraParallelRunsEveryIndexWithinWorkerLimit(t *testing.T) { + t.Parallel() + + const ( + workers = 3 + n = 9 + ) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + release := make(chan struct{}) + var releaseOnce sync.Once + var ( + mu sync.Mutex + inFlight int + maxSeen int + started int + seen = make(map[int]int) + ) + jiraParallel(ctx, workers, n, func(ctx context.Context, i int) { + mu.Lock() + inFlight++ + started++ + if inFlight > maxSeen { + maxSeen = inFlight + } + if started >= workers { + // Release once the pool has filled its slots. If it never does, the + // context deadline ends the wait instead of hanging the test. + releaseOnce.Do(func() { close(release) }) + } + mu.Unlock() + + select { + case <-release: + case <-ctx.Done(): + } + + mu.Lock() + inFlight-- + seen[i]++ + mu.Unlock() + }) + + if len(seen) != n { + t.Fatalf("ran %d distinct indices, want %d", len(seen), n) + } + for i := 0; i < n; i++ { + if seen[i] != 1 { + t.Fatalf("index %d ran %d times, want once", i, seen[i]) + } + } + if maxSeen > workers { + t.Fatalf("max concurrency = %d, want <= %d", maxSeen, workers) + } + if maxSeen < 2 { + t.Fatalf("max concurrency = %d, want the pool to overlap work", maxSeen) + } +} + +func TestJiraParallelSequentialFallbackKeepsOrder(t *testing.T) { + t.Parallel() + + var order []int + jiraParallel(context.Background(), 1, 4, func(_ context.Context, i int) { + order = append(order, i) + }) + if len(order) != 4 { + t.Fatalf("ran %d indices, want 4", len(order)) + } + for i, got := range order { + if got != i { + t.Fatalf("order = %v, want ascending", order) + } + } +} + +func TestJiraParallelStopsWhenContextCancelled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + var ran atomic.Int32 + jiraParallel(ctx, 2, 1000, func(_ context.Context, _ int) { + ran.Add(1) + cancel() + }) + if ran.Load() >= 1000 { + t.Fatalf("ran %d tasks after cancellation, want the pool to stop early", ran.Load()) + } +} + +// TestWithImportConcurrencyKeepsDefaultAndClamps keeps the pool bounded even +// when an operator supplies an unusable or extreme value. +func TestWithImportConcurrencyKeepsDefaultAndClamps(t *testing.T) { + t.Parallel() + + handler := NewJiraImportHandler(nil, "jira-concurrency-config-secret", "", blobstore.Disabled()) + if handler.importConcurrency != jiraImportDefaultConcurrency { + t.Fatalf("default concurrency = %d, want %d", handler.importConcurrency, jiraImportDefaultConcurrency) + } + if got := handler.WithImportConcurrency(0).importConcurrency; got != jiraImportDefaultConcurrency { + t.Fatalf("concurrency after 0 = %d, want the default %d", got, jiraImportDefaultConcurrency) + } + if got := handler.WithImportConcurrency(3).importConcurrency; got != 3 { + t.Fatalf("concurrency after 3 = %d, want 3", got) + } + if got := handler.WithImportConcurrency(1000).importConcurrency; got != jiraImportMaxConcurrency { + t.Fatalf("concurrency after 1000 = %d, want the max %d", got, jiraImportMaxConcurrency) + } +} + +// TestCompleteJiraBatchSubresourcesFoldsFailuresFromEveryIssue proves the +// concurrent batch fan-out still attributes each issue's comment/worklog loss +// exactly once. +func TestCompleteJiraBatchSubresourcesFoldsFailuresFromEveryIssue(t *testing.T) { + t.Parallel() + + const issueCount = 12 + issues := make([]jira.JiraIssue, issueCount) + for i := range issues { + issues[i] = jira.JiraIssue{ + Key: fmt.Sprintf("APP-%d", i+1), + Fields: jira.JiraIssueFields{ + Comment: &jira.JiraCommentContainer{ + Comments: []jira.JiraComment{comment("c1")}, + Total: 2, + MaxResults: 1, + }, + }, + } + } + client := &fakeJiraSubresourceClient{ + getComments: func(int) (*jira.JiraCommentContainer, error) { return nil, errors.New("comments unavailable") }, + getWorklogs: func(int) (*jira.JiraWorklogContainer, error) { return nil, errors.New("unused") }, + } + im := &jiraImportContext{progress: &ImportProgress{}, client: client} + handler := &JiraImportHandler{importConcurrency: 4} + + handler.completeJiraBatchSubresources(t.Context(), im, "APP", issues, nil) + + if im.progress.FailedComments != issueCount { + t.Fatalf("FailedComments = %d, want %d", im.progress.FailedComments, issueCount) + } + if im.progress.FailedWorklogs != 0 { + t.Fatalf("FailedWorklogs = %d, want 0", im.progress.FailedWorklogs) + } + for i := range issues { + if len(issues[i].Fields.Comment.Comments) != 1 || issues[i].Fields.Comment.Total != 2 { + t.Fatalf("issue %d comment container = %#v, want the embedded page preserved", i, issues[i].Fields.Comment) + } + } +} diff --git a/internal/handlers/jira_issue_number_test.go b/internal/handlers/jira_issue_number_test.go new file mode 100644 index 0000000..94993b2 --- /dev/null +++ b/internal/handlers/jira_issue_number_test.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/database" + "windshift/internal/jira" + "windshift/internal/services" +) + +func jiraIssueWithNumber(id, key, summary string) jira.JiraIssue { + return jira.JiraIssue{ + ID: id, + Key: key, + Fields: jira.JiraIssueFields{ + Summary: summary, + IssueType: &jira.JiraIssueType{ID: "type"}, + Status: &jira.JiraStatus{ID: "status"}, + }, + } +} + +func itemNumberByTitle(t *testing.T, db database.Database, title string) int { + t.Helper() + var number int + if err := db.QueryRow(`SELECT workspace_item_number FROM items WHERE title = ?`, title).Scan(&number); err != nil { + t.Fatalf("load item number for %q: %v", title, err) + } + return number +} + +func TestJiraIssueNumberParsesNumericSuffix(t *testing.T) { + tests := []struct { + key string + want int + ok bool + }{ + {key: "EXPL-30", want: 30, ok: true}, + {key: "PROJECT-WITH-DASHES-42", want: 42, ok: true}, + {key: "EXPL-0", ok: false}, + {key: "EXPL-", ok: false}, + {key: "EXPL", ok: false}, + {key: "EXPL-abc", ok: false}, + } + for _, test := range tests { + got, ok := jiraIssueNumber(test.key) + if got != test.want || ok != test.ok { + t.Fatalf("jiraIssueNumber(%q) = (%d, %t), want (%d, %t)", test.key, got, ok, test.want, test.ok) + } + } +} + +func TestJiraImportPreservesIssueNumberAsWorkspaceItemNumber(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-issue-number-preserve-secret", "", blobstore.Disabled()) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + + // Imported out of numeric order: the pinned numbers come from the Jira keys, + // not the insertion position. + for _, issue := range []jira.JiraIssue{ + jiraIssueWithNumber("10030", "APP-30", "Thirty"), + jiraIssueWithNumber("10005", "APP-5", "Five"), + jiraIssueWithNumber("10002", "APP-2", "Two"), + } { + issue := issue + importJiraReimportIssue(t, handler, "job-jsm", workspaceID, itemTypeID, statusID, &issue, false) + } + + for _, want := range []struct { + title string + number int + }{{"Two", 2}, {"Five", 5}, {"Thirty", 30}} { + if got := itemNumberByTitle(t, db, want.title); got != want.number { + t.Fatalf("item %q number = %d, want %d", want.title, got, want.number) + } + } +} + +func TestJiraImportFallsBackWhenPinnedIssueNumberTaken(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-issue-number-taken-secret", "", blobstore.Disabled()) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + + pinned := 5 + if _, err := services.CreateItem(db, services.ItemCreationParams{ + WorkspaceID: workspaceID, + Title: "Existing five", + PinnedItemNumber: &pinned, + }); err != nil { + t.Fatalf("seed item with pinned number 5: %v", err) + } + + issue := jiraIssueWithNumber("10005", "APP-5", "Imported five") + importJiraReimportIssue(t, handler, "job-jsm", workspaceID, itemTypeID, statusID, &issue, false) + + if got := itemNumberByTitle(t, db, "Existing five"); got != 5 { + t.Fatalf("existing item number = %d, want unchanged 5", got) + } + if got := itemNumberByTitle(t, db, "Imported five"); got != 6 { + t.Fatalf("imported item number = %d, want fallback 6", got) + } + + var metadataJSON string + if err := db.QueryRow(` + SELECT metadata_json FROM jira_import_id_mappings + WHERE job_id = 'job-jsm' AND entity_type = 'fidelity_finding' AND jira_id = 'item-number:APP-5' + `).Scan(&metadataJSON); err != nil { + t.Fatalf("load issue-number fallback finding: %v", err) + } + var metadata map[string]any + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + t.Fatalf("decode fallback finding metadata: %v", err) + } + if metadata["code"] != "jira_issue_number_taken" { + t.Fatalf("finding code = %v, want jira_issue_number_taken", metadata["code"]) + } + if metadata["requested_number"] != float64(5) || metadata["assigned_number"] != float64(6) { + t.Fatalf("finding numbers = %v/%v, want 5/6", metadata["requested_number"], metadata["assigned_number"]) + } +} + +func TestJiraImportFallsBackWhenPinnedIssueNumberReserved(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-issue-number-reserved-secret", "", blobstore.Disabled()) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + + // A delete retires the number, so APP-9 is not free even though no item holds it. + retired := 9 + retiredID, err := services.CreateItem(db, services.ItemCreationParams{ + WorkspaceID: workspaceID, + Title: "Retired nine", + PinnedItemNumber: &retired, + }) + if err != nil { + t.Fatalf("seed item with pinned number 9: %v", err) + } + if err := services.NewItemCRUDService(db).DeleteSingle(int(retiredID)); err != nil { + t.Fatalf("retire seeded item: %v", err) + } + + issue := jiraIssueWithNumber("10009", "APP-9", "Imported nine") + importJiraReimportIssue(t, handler, "job-jsm", workspaceID, itemTypeID, statusID, &issue, false) + + if got := itemNumberByTitle(t, db, "Imported nine"); got != 10 { + t.Fatalf("imported item number = %d, want fallback 10", got) + } +} + +func TestJiraReimportKeepsPinnedIssueNumber(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-issue-number-reimport-secret", "", blobstore.Disabled()) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + + issue := jiraIssueWithNumber("10007", "APP-7", "Seven") + importJiraReimportIssue(t, handler, "job-jsm", workspaceID, itemTypeID, statusID, &issue, false) + if got := itemNumberByTitle(t, db, "Seven"); got != 7 { + t.Fatalf("initial item number = %d, want 7", got) + } + + // Push the workspace max above the pinned number so a re-import that created + // a fresh item would be numbered past 100. + high := 100 + if _, err := services.CreateItem(db, services.ItemCreationParams{ + WorkspaceID: workspaceID, + Title: "High number", + PinnedItemNumber: &high, + }); err != nil { + t.Fatalf("seed high-numbered item: %v", err) + } + + insertJiraImportJob(t, db, "job-reimport", "conn-jsm", "running", []string{"APP"}) + issue.Fields.Summary = "Seven updated" + importJiraReimportIssue(t, handler, "job-reimport", workspaceID, itemTypeID, statusID, &issue, true) + + if got := itemNumberByTitle(t, db, "Seven updated"); got != 7 { + t.Fatalf("reimported item number = %d, want stable 7", got) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM items WHERE workspace_id = ? AND title = 'Seven updated'`, workspaceID).Scan(&count); err != nil { + t.Fatalf("count reimported items: %v", err) + } + if count != 1 { + t.Fatalf("reimported item rows = %d, want 1", count) + } +} diff --git a/internal/handlers/jira_issue_subresources_test.go b/internal/handlers/jira_issue_subresources_test.go index eef57f4..a99a3a5 100644 --- a/internal/handlers/jira_issue_subresources_test.go +++ b/internal/handlers/jira_issue_subresources_test.go @@ -77,7 +77,7 @@ func TestJiraIssueSubresourcesCompletePaginationVisibilityAndWatchers(t *testing }, } - if err := handler.completePagedIssueContainers(t.Context(), issue, client, nil); err != nil { + if _, _, err := handler.completeJiraIssueSubresources(t.Context(), issue, client); err != nil { t.Fatalf("complete paged containers: %v", err) } handler.completeIssueWatchers(t.Context(), issue, client) diff --git a/internal/handlers/jira_rank_import_test.go b/internal/handlers/jira_rank_import_test.go index 4c62741..8f9f16f 100644 --- a/internal/handlers/jira_rank_import_test.go +++ b/internal/handlers/jira_rank_import_test.go @@ -32,17 +32,32 @@ func (f *fakeJiraRankIssueKeyClient) GetAllIssueKeys(_ context.Context, jql stri return f.fallbackKeys, f.fallbackErr } +func (f *fakeJiraRankIssueKeyClient) GetAllIssueRefs(_ context.Context, jql string) ([]jira.JiraIssueRef, error) { + keys, err := f.GetAllIssueKeys(context.Background(), jql) + if err != nil { + return nil, err + } + refs := make([]jira.JiraIssueRef, 0, len(keys)) + for index, key := range keys { + refs = append(refs, jira.JiraIssueRef{ID: strconv.Itoa(10000 + index), Key: key}) + } + return refs, nil +} + func TestGetJiraIssueKeysInImportOrderUsesRankAndDeterministicTies(t *testing.T) { t.Parallel() client := &fakeJiraRankIssueKeyClient{rankedKeys: []string{"SP-3", "SP-1", "SP-2"}} - got, err := getJiraIssueKeysInImportOrder(t.Context(), client, "SP", true) + got, keysByID, err := getJiraIssueKeysInImportOrder(t.Context(), client, "SP", true) if err != nil { t.Fatalf("get ranked Jira issue keys: %v", err) } if !reflect.DeepEqual(got, client.rankedKeys) { t.Fatalf("ranked keys = %#v, want %#v", got, client.rankedKeys) } + if keysByID["10000"] != "SP-3" || keysByID["10001"] != "SP-1" || keysByID["10002"] != "SP-2" { + t.Fatalf("id→key correlation = %#v, want discovery ids mapped to keys", keysByID) + } wantJQL := "project = SP AND statusCategory != Done ORDER BY Rank ASC, created ASC, key ASC" if !reflect.DeepEqual(client.calls, []string{wantJQL}) { t.Fatalf("JQL calls = %#v, want %#v", client.calls, []string{wantJQL}) @@ -56,7 +71,7 @@ func TestGetJiraIssueKeysInImportOrderFallsBackWhenRankIsUnavailable(t *testing. rankedErr: errors.New("field 'Rank' does not exist"), fallbackKeys: []string{"CORE-1", "CORE-2"}, } - got, err := getJiraIssueKeysInImportOrder(t.Context(), client, "CORE", false) + got, _, err := getJiraIssueKeysInImportOrder(t.Context(), client, "CORE", false) if err != nil { t.Fatalf("get fallback Jira issue keys: %v", err) } @@ -79,7 +94,7 @@ func TestGetJiraIssueKeysInImportOrderReportsRankAndFallbackFailures(t *testing. rankedErr: errors.New("rank query failed"), fallbackErr: errors.New("fallback query failed"), } - _, err := getJiraIssueKeysInImportOrder(t.Context(), client, "CORE", false) + _, _, err := getJiraIssueKeysInImportOrder(t.Context(), client, "CORE", false) if err == nil { t.Fatal("expected issue-key query failure") } diff --git a/internal/handlers/jira_readiness_incomplete_test.go b/internal/handlers/jira_readiness_incomplete_test.go new file mode 100644 index 0000000..7fd25ea --- /dev/null +++ b/internal/handlers/jira_readiness_incomplete_test.go @@ -0,0 +1,132 @@ +//go:build test + +package handlers + +import ( + "context" + "errors" + "fmt" + "testing" + + "windshift/internal/jira" +) + +// readinessPageFailingClient serves full pages and then fails the requested +// page, so readiness sampling cannot silently treat a rejected page as a +// complete sample. +type readinessPageFailingClient struct { + jira.Client + pageSize int + successes int + calls int +} + +func (c *readinessPageFailingClient) GetProject(context.Context, string) (*jira.JiraProject, error) { + return &jira.JiraProject{ID: "P1", Key: "CT", Name: "CT", Style: "classic"}, nil +} + +func (c *readinessPageFailingClient) GetProjectFields(context.Context, []string) ([]jira.JiraCustomField, error) { + return nil, nil +} + +func (c *readinessPageFailingClient) GetIssueCount(context.Context, string, bool) (int, error) { + return 250, nil +} + +func (c *readinessPageFailingClient) ListBoards(context.Context, string) (*jira.BoardListResult, error) { + return &jira.BoardListResult{}, nil +} + +func (c *readinessPageFailingClient) GetProjectIssueTypes(context.Context, string) ([]jira.JiraIssueType, error) { + return nil, errors.New("issue types unavailable") +} + +func (c *readinessPageFailingClient) SearchIssuesJQL(_ context.Context, _ jira.JQLSearchRequest) (*jira.JQLSearchResponse, error) { + c.calls++ + if c.calls > c.successes { + return nil, errors.New("jira API error: status 400 - invalid JQL") + } + issues := make([]jira.JiraIssue, c.pageSize) + for i := range issues { + issues[i].Key = fmt.Sprintf("CT-%d", (c.calls-1)*c.pageSize+i+1) + } + return &jira.JQLSearchResponse{Issues: issues, NextPageToken: "next-page"}, nil +} + +// TestReadinessMarksRejectedSamplePagesIncomplete is the WI-2063 regression: +// a rejected first or later sampling page must produce an explicit incomplete +// assessment with exact sampled counts instead of an apparently complete score. +func TestReadinessMarksRejectedSamplePagesIncomplete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + successes int + wantSampled int + }{ + {name: "rejected first page", successes: 0, wantSampled: 0}, + {name: "rejected later page", successes: 1, wantSampled: 100}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + client := &readinessPageFailingClient{pageSize: 100, successes: test.successes} + report := (&JiraImportHandler{}).analyzeReadiness(context.Background(), client, JiraReadinessRequest{ + ProjectKeys: []string{"CT"}, + SampleSize: 200, + }) + + if report.AssessmentComplete { + t.Fatal("assessment marked complete after a rejected sampling page") + } + if len(report.Projects) != 1 { + t.Fatalf("projects = %d, want 1", len(report.Projects)) + } + project := report.Projects[0] + if project.AssessmentComplete { + t.Fatal("project assessment marked complete after a rejected sampling page") + } + if project.SampledIssues != test.wantSampled || report.SampledIssues != test.wantSampled { + t.Fatalf("sampled issues = project %d report %d, want %d", + project.SampledIssues, report.SampledIssues, test.wantSampled) + } + if project.AssessmentError == "" { + t.Fatal("assessment error is empty, want the upstream failure") + } + found := false + for _, finding := range project.Findings { + if finding.Category == "assessment" { + found = true + if finding.Severity != jira.SeverityBlocked { + t.Fatalf("assessment finding severity = %q, want blocked", finding.Severity) + } + } + } + if !found { + t.Fatalf("assessment finding missing from %#v", project.Findings) + } + }) + } +} + +// TestReadinessCompleteSampleIsMarkedComplete guards the success path. +func TestReadinessCompleteSampleIsMarkedComplete(t *testing.T) { + t.Parallel() + + client := &readinessPageFailingClient{pageSize: 100, successes: 5} + report := (&JiraImportHandler{}).analyzeReadiness(context.Background(), client, JiraReadinessRequest{ + ProjectKeys: []string{"CT"}, + SampleSize: 200, + }) + + if !report.AssessmentComplete || !report.Projects[0].AssessmentComplete { + t.Fatalf("assessment completeness = report %t project %t, want true/true", + report.AssessmentComplete, report.Projects[0].AssessmentComplete) + } + if report.SampledIssues != 200 { + t.Fatalf("sampled issues = %d, want 200", report.SampledIssues) + } +} diff --git a/internal/handlers/jira_reimport_test.go b/internal/handlers/jira_reimport_test.go index b8cbd3c..0e6fe4f 100644 --- a/internal/handlers/jira_reimport_test.go +++ b/internal/handlers/jira_reimport_test.go @@ -20,7 +20,7 @@ type fakeJiraAttachmentImportClient struct { func (f *fakeJiraAttachmentImportClient) DownloadAttachment( _ context.Context, - _ string, + _, _ string, ) (io.ReadCloser, string, error) { return io.NopCloser(strings.NewReader("attachment evidence\n")), "text/plain", nil } @@ -275,7 +275,7 @@ func TestForcedJiraReimportTransfersInternalLinkOwnershipWithoutDuplicates(t *te func TestJiraWatchersBecomeItemWatchesAndVotesAndSecurityRemainRetrievable(t *testing.T) { db := newJiraServiceManagementTestDB(t) - handler := NewJiraImportHandler(db, "jira-watchers-test-secret", "", blobstore.Disabled()) + handler := NewJiraImportHandler(db, "jira-watchers-test-secret", "", blobstore.Disabled()).WithImportWatchers(true) workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) var userID int @@ -381,6 +381,84 @@ func TestJiraWatchersBecomeItemWatchesAndVotesAndSecurityRemainRetrievable(t *te } } +// TestJiraWatchersAreNotImportedByDefault is the WI-1983 contract: the +// per-issue watcher fetch and item-watch import are opt-in, so a default import +// writes no watches or watcher-identity metadata while keeping the watcher +// count that the bulk fetch already returned. +func TestJiraWatchersAreNotImportedByDefault(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + handler := NewJiraImportHandler(db, "jira-watchers-default-test-secret", "", blobstore.Disabled()) + workspaceID, itemTypeID, statusID := createJiraReimportWorkspace(t, db) + + issue := jira.JiraIssue{ + ID: "10003", + Key: "APP-3", + Fields: jira.JiraIssueFields{ + Summary: "Watched issue", + IssueType: &jira.JiraIssueType{ID: "type"}, + Status: &jira.JiraStatus{ID: "status"}, + Watches: &jira.JiraWatchSummary{WatchCount: 2}, + Watchers: []jira.JiraUser{ + {AccountID: "watcher-1", DisplayName: "Jira Watcher"}, + }, + WatcherIdentitiesAvailable: true, + }, + } + im := &jiraImportContext{ + jobID: "job-jsm", + progress: &ImportProgress{}, + workspaceID: workspaceID, + statusMap: map[string]int{"status": statusID}, + itemTypeMap: map[string]int{"type": itemTypeID}, + userMap: map[string]int{}, + usernameMap: map[string]string{}, + portalCustomerMap: map[string]int{}, + versionMap: map[string]int{}, + iterationMap: map[string]int{}, + customFieldIDMap: map[string]int{}, + choiceOptionIDs: map[string]map[string]int{}, + } + if err := handler.importIssue(t.Context(), im, &issue); err != nil { + t.Fatalf("import issue: %v", err) + } + + var itemID int + var customFieldValuesJSON string + if err := db.QueryRow(`SELECT id, custom_field_values FROM items WHERE workspace_id = ?`, workspaceID).Scan(&itemID, &customFieldValuesJSON); err != nil { + t.Fatalf("load imported item: %v", err) + } + + var watchCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM item_watches WHERE item_id = ?`, itemID).Scan(&watchCount); err != nil { + t.Fatalf("count item watches: %v", err) + } + if watchCount != 0 { + t.Fatalf("item watches = %d, want none when watcher import is disabled", watchCount) + } + + var mappingCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM jira_import_id_mappings WHERE job_id = 'job-jsm' AND entity_type = 'watch'`).Scan(&mappingCount); err != nil { + t.Fatalf("count watch mappings: %v", err) + } + if mappingCount != 0 { + t.Fatalf("watch mappings = %d, want none when watcher import is disabled", mappingCount) + } + + var metadata map[string]interface{} + if err := json.Unmarshal([]byte(customFieldValuesJSON), &metadata); err != nil { + t.Fatalf("decode item metadata: %v", err) + } + if metadata["_jira_watcher_count"] != float64(2) { + t.Fatalf("watcher count metadata = %#v, want the bulk-fetch count preserved", metadata["_jira_watcher_count"]) + } + if _, present := metadata["_jira_watchers"]; present { + t.Fatalf("watcher identity metadata present with watcher import disabled: %#v", metadata) + } + if _, present := metadata["_jira_watcher_identities_available"]; present { + t.Fatalf("watcher availability metadata present with watcher import disabled: %#v", metadata) + } +} + func TestJiraADFMediaLinksToImportedAttachmentInItemAndComment(t *testing.T) { db := newJiraServiceManagementTestDB(t) attachmentRoot := t.TempDir() diff --git a/internal/handlers/jira_request_type_restriction_test.go b/internal/handlers/jira_request_type_restriction_test.go new file mode 100644 index 0000000..246cc94 --- /dev/null +++ b/internal/handlers/jira_request_type_restriction_test.go @@ -0,0 +1,266 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "windshift/internal/auth" + "windshift/internal/blobstore" + "windshift/internal/database" + "windshift/internal/jira" + "windshift/internal/jiraimport" + "windshift/internal/middleware" +) + +func TestClassifyJiraRequestTypeRestrictionFailsClosed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status string + wantStatus string + wantDisposition string + wantFailClosed bool + }{ + {name: "open is active", status: "OPEN", wantStatus: "OPEN", wantDisposition: "open", wantFailClosed: false}, + {name: "open lower case", status: "open", wantStatus: "OPEN", wantDisposition: "open", wantFailClosed: false}, + {name: "restricted", status: "RESTRICTED", wantStatus: "RESTRICTED", wantDisposition: "restricted", wantFailClosed: true}, + {name: "undocumented closed", status: "CLOSED", wantStatus: "CLOSED", wantDisposition: "unknown", wantFailClosed: true}, + {name: "missing status", status: "", wantStatus: "", wantDisposition: "unknown", wantFailClosed: true}, + {name: "whitespace only", status: " ", wantStatus: "", wantDisposition: "unknown", wantFailClosed: true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := classifyJiraRequestTypeRestriction(test.status) + if got.SourceStatus != test.wantStatus || got.Disposition != test.wantDisposition || got.failClosed() != test.wantFailClosed { + t.Fatalf("classify(%q) = %#v failClosed=%t, want status %q disposition %q failClosed %t", + test.status, got, got.failClosed(), test.wantStatus, test.wantDisposition, test.wantFailClosed) + } + }) + } +} + +func requestTypeFixture(t *testing.T) (database.Database, *JiraImportHandler, int, int) { + t.Helper() + db := newJiraServiceManagementTestDB(t) + h := NewJiraImportHandler(db, "jira-jsm-restriction-test-secret", "", blobstore.Disabled()) + + var workspaceID int + if err := db.QueryRow(` + INSERT INTO workspaces (name, key, active) VALUES ('Help Desk', 'HELP', true) RETURNING id + `).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + var itemTypeID int + if err := db.QueryRow(`SELECT id FROM item_types ORDER BY id LIMIT 1`).Scan(&itemTypeID); err != nil { + t.Fatalf("find item type: %v", err) + } + return db, h, workspaceID, itemTypeID +} + +func importedRequestTypeState(t *testing.T, db database.Database, id int) (active bool, metadata map[string]any) { + t.Helper() + if err := db.QueryRow(`SELECT is_active FROM request_types WHERE id = ?`, id).Scan(&active); err != nil { + t.Fatalf("load request type %d active flag: %v", id, err) + } + var metadataJSON string + if err := db.QueryRow(` + SELECT metadata_json FROM jira_import_id_mappings + WHERE job_id = 'job-jsm' AND entity_type = 'request_type' AND windshift_id = ? + `, id).Scan(&metadataJSON); err != nil { + t.Fatalf("load request type %d mapping metadata: %v", id, err) + } + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + t.Fatalf("decode request type %d mapping metadata: %v", id, err) + } + return active, metadata +} + +func TestEnsureJiraRequestTypesFailsClosedForRestrictedAndUnknownStatuses(t *testing.T) { + db, h, workspaceID, itemTypeID := requestTypeFixture(t) + channelID, err := h.ensureJiraPortal(context.Background(), "job-jsm", + jira.JiraProject{ID: "10000", Key: "HELP", Name: "Help Desk", ProjectType: "service_desk"}, + jira.JiraServiceDesk{ID: "1", ProjectID: "10000", ProjectKey: "HELP"}, + workspaceID, 0) + if err != nil { + t.Fatalf("ensure Jira portal: %v", err) + } + + requestTypeMap, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{ + {ID: "1", Name: "Open form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "OPEN"}, + {ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "RESTRICTED"}, + {ID: "3", Name: "Unknown form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "CLOSED"}, + }, map[string]int{"10003": itemTypeID}) + if err != nil { + t.Fatalf("ensure Jira request types: %v", err) + } + + expectations := []struct { + jiraID string + wantActive bool + wantStatus string + wantDispose string + }{ + {jiraID: "1", wantActive: true, wantStatus: "OPEN", wantDispose: jiraimport.RequestTypeDispositionActive}, + {jiraID: "2", wantActive: false, wantStatus: "RESTRICTED", wantDispose: jiraimport.RequestTypeDispositionDisabledNeedsReview}, + {jiraID: "3", wantActive: false, wantStatus: "CLOSED", wantDispose: jiraimport.RequestTypeDispositionDisabledNeedsReview}, + } + for _, expectation := range expectations { + id := requestTypeMap[expectation.jiraID] + if id == 0 { + t.Fatalf("request type %s was not imported", expectation.jiraID) + } + active, metadata := importedRequestTypeState(t, db, id) + if active != expectation.wantActive { + t.Fatalf("request type %s active = %t, want %t", expectation.jiraID, active, expectation.wantActive) + } + if metadata["restriction_status"] != expectation.wantStatus || + metadata["restriction_disposition"] != expectation.wantDispose { + t.Fatalf("request type %s restriction metadata = %#v, want status %q disposition %q", + expectation.jiraID, metadata, expectation.wantStatus, expectation.wantDispose) + } + } +} + +func TestReimportRestrictedJiraRequestTypeDisablesPreviouslyActiveType(t *testing.T) { + db, h, workspaceID, itemTypeID := requestTypeFixture(t) + channelID, err := h.ensureJiraPortal(context.Background(), "job-jsm", + jira.JiraProject{ID: "10000", Key: "HELP", Name: "Help Desk", ProjectType: "service_desk"}, + jira.JiraServiceDesk{ID: "1", ProjectID: "10000", ProjectKey: "HELP"}, + workspaceID, 0) + if err != nil { + t.Fatalf("ensure Jira portal: %v", err) + } + + firstMap, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{{ + ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "OPEN", + }}, map[string]int{"10003": itemTypeID}) + if err != nil { + t.Fatalf("initial Jira request type import: %v", err) + } + if active, _ := importedRequestTypeState(t, db, firstMap["2"]); !active { + t.Fatal("initially open request type was not active") + } + + reimportMap, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{{ + ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "RESTRICTED", + }}, map[string]int{"10003": itemTypeID}) + if err != nil { + t.Fatalf("reimport Jira request type: %v", err) + } + if reimportMap["2"] != firstMap["2"] { + t.Fatalf("reimport created a new request type: %d != %d", reimportMap["2"], firstMap["2"]) + } + active, metadata := importedRequestTypeState(t, db, reimportMap["2"]) + if active { + t.Fatal("request type stayed active after Jira restricted it") + } + if metadata["restriction_status"] != "RESTRICTED" || + metadata["restriction_disposition"] != jiraimport.RequestTypeDispositionDisabledNeedsReview { + t.Fatalf("reimport restriction metadata = %#v, want restricted/needs-review", metadata) + } +} + +func TestReimportRestrictedJiraRequestTypePreservesConfiguredAudience(t *testing.T) { + db, h, workspaceID, itemTypeID := requestTypeFixture(t) + channelID, err := h.ensureJiraPortal(context.Background(), "job-jsm", + jira.JiraProject{ID: "10000", Key: "HELP", Name: "Help Desk", ProjectType: "service_desk"}, + jira.JiraServiceDesk{ID: "1", ProjectID: "10000", ProjectKey: "HELP"}, + workspaceID, 0) + if err != nil { + t.Fatalf("ensure Jira portal: %v", err) + } + + firstMap, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{{ + ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "RESTRICTED", + }}, map[string]int{"10003": itemTypeID}) + if err != nil { + t.Fatalf("initial Jira request type import: %v", err) + } + requestTypeID := firstMap["2"] + + // An administrator maps the restricted source type to a specific portal + // audience and enables it. + if _, err := db.ExecWrite(` + UPDATE request_types SET visibility_org_ids = '[42]', is_active = true WHERE id = ? + `, requestTypeID); err != nil { + t.Fatalf("configure request type audience: %v", err) + } + + if _, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{{ + ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "RESTRICTED", + }}, map[string]int{"10003": itemTypeID}); err != nil { + t.Fatalf("reimport Jira request type: %v", err) + } + active, metadata := importedRequestTypeState(t, db, requestTypeID) + if !active { + t.Fatal("administrator-configured audience was deactivated by reimport") + } + if metadata["restriction_disposition"] != jiraimport.RequestTypeDispositionAudienceConfigured { + t.Fatalf("restriction disposition = %v, want %q", metadata["restriction_disposition"], jiraimport.RequestTypeDispositionAudienceConfigured) + } +} + +func TestImportedRestrictedJiraRequestTypeDeniedToUnrelatedPortalUser(t *testing.T) { + db, h, workspaceID, itemTypeID := requestTypeFixture(t) + channelID, err := h.ensureJiraPortal(context.Background(), "job-jsm", + jira.JiraProject{ID: "10000", Key: "HELP", Name: "Help Desk", ProjectType: "service_desk"}, + jira.JiraServiceDesk{ID: "1", ProjectID: "10000", ProjectKey: "HELP"}, + workspaceID, 0) + if err != nil { + t.Fatalf("ensure Jira portal: %v", err) + } + requestTypeMap, err := h.ensureJiraRequestTypes("job-jsm", channelID, workspaceID, []jira.JiraServiceDeskRequestType{ + {ID: "1", Name: "Open form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "OPEN"}, + {ID: "2", Name: "Restricted form", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "RESTRICTED"}, + }, map[string]int{"10003": itemTypeID}) + if err != nil { + t.Fatalf("ensure Jira request types: %v", err) + } + + var unrelatedUserID int + if err := db.QueryRow(` + INSERT INTO users (email, username, first_name, last_name, is_active) + VALUES ('unrelated@example.test', 'jira-unrelated', 'Unrelated', 'User', true) RETURNING id + `).Scan(&unrelatedUserID); err != nil { + t.Fatalf("create unrelated portal user: %v", err) + } + + portalHandler := NewPortalHandler(db, nil, nil, nil, blobstore.Disabled()) + resolve := func(requestTypeID int) (*httptest.ResponseRecorder, bool) { + req := httptest.NewRequest(http.MethodPost, "/api/portal/jira-help/submit", nil) + req = req.WithContext(context.WithValue(req.Context(), middleware.ContextKeySession, &auth.Session{UserID: unrelatedUserID})) + rec := httptest.NewRecorder() + _, ok := portalHandler.resolveVisibleRequestType(req.Context(), rec, req, channelID, requestTypeID) + return rec, ok + } + + openRec, openOK := resolve(requestTypeMap["1"]) + if !openOK || openRec.Code != http.StatusOK { + t.Fatalf("open request type resolution = ok %t code %d, want ok/200", openOK, openRec.Code) + } + + restrictedRec, restrictedOK := resolve(requestTypeMap["2"]) + if restrictedOK { + t.Fatal("restricted request type resolved for an unrelated portal user") + } + if restrictedRec.Code != http.StatusNotFound { + t.Fatalf("restricted request type status = %d, want 404", restrictedRec.Code) + } + var denial struct { + Error string `json:"error"` + } + if err := json.Unmarshal(restrictedRec.Body.Bytes(), &denial); err != nil { + t.Fatalf("decode denial: %v; body=%s", err, restrictedRec.Body.String()) + } + if denial.Error != "Request type not found" { + t.Fatalf("denial error = %q, want %q", denial.Error, "Request type not found") + } +} diff --git a/internal/handlers/jira_service_management_degradation_test.go b/internal/handlers/jira_service_management_degradation_test.go new file mode 100644 index 0000000..4f5b20b --- /dev/null +++ b/internal/handlers/jira_service_management_degradation_test.go @@ -0,0 +1,82 @@ +//go:build test + +package handlers + +import ( + "context" + "errors" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +type fakeJiraServiceManagementClient struct { + jira.Client + organizationErr error +} + +func (c *fakeJiraServiceManagementClient) GetProject(context.Context, string) (*jira.JiraProject, error) { + return &jira.JiraProject{ID: "10000", Key: "HELP", Name: "Help Desk", ProjectType: "service_desk"}, nil +} + +func (c *fakeJiraServiceManagementClient) ListServiceDesks(context.Context) ([]jira.JiraServiceDesk, error) { + return []jira.JiraServiceDesk{{ID: "1", ProjectID: "10000", ProjectKey: "HELP"}}, nil +} + +func (c *fakeJiraServiceManagementClient) ListServiceDeskRequestTypes(context.Context, string) ([]jira.JiraServiceDeskRequestType, error) { + return []jira.JiraServiceDeskRequestType{{ + ID: "1", Name: "Get help", IssueTypeID: "10003", ServiceDeskID: "1", RestrictionStatus: "OPEN", + }}, nil +} + +func (c *fakeJiraServiceManagementClient) ListServiceDeskOrganizations(context.Context, string) ([]jira.JiraServiceDeskOrganization, error) { + return nil, c.organizationErr +} + +func (c *fakeJiraServiceManagementClient) ListServiceDeskOrganizationUsers(context.Context, string) ([]jira.JiraUser, error) { + return nil, nil +} + +// TestPrepareJiraServiceManagementImportDegradesWhenOrganizationsUnavailable is +// the WI-2074 regression: a rejected experimental organization read must not +// abort the project's core issue import. +func TestPrepareJiraServiceManagementImportDegradesWhenOrganizationsUnavailable(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + h := NewJiraImportHandler(db, "jira-jsm-degradation-secret", "", blobstore.Disabled()) + + var workspaceID int + if err := db.QueryRow(` + INSERT INTO workspaces (name, key, active) VALUES ('Help Desk', 'HELP', true) RETURNING id + `).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + var itemTypeID int + if err := db.QueryRow(`SELECT id FROM item_types ORDER BY id LIMIT 1`).Scan(&itemTypeID); err != nil { + t.Fatalf("find item type: %v", err) + } + + client := &fakeJiraServiceManagementClient{organizationErr: errors.New("X-ExperimentalApi header required")} + jsm, err := h.prepareJiraServiceManagementImport( + context.Background(), + "job-jsm", + "HELP", + workspaceID, + map[string]int{"10003": itemTypeID}, + client, + 0, + true, + ) + if err != nil { + t.Fatalf("prepareJiraServiceManagementImport() error = %v, want graceful degradation", err) + } + if jsm == nil { + t.Fatal("prepareJiraServiceManagementImport() returned nil import, want the request-type catalog") + } + if len(jsm.OrganizationCustomers) != 0 { + t.Fatalf("OrganizationCustomers = %#v, want none when organization metadata is unavailable", jsm.OrganizationCustomers) + } + if len(jsm.RequestTypes) != 1 { + t.Fatalf("RequestTypes = %#v, want the discovered request type retained", jsm.RequestTypes) + } +} diff --git a/internal/handlers/jira_subresource_completion_test.go b/internal/handlers/jira_subresource_completion_test.go index 864749a..8fd8f2f 100644 --- a/internal/handlers/jira_subresource_completion_test.go +++ b/internal/handlers/jira_subresource_completion_test.go @@ -171,15 +171,16 @@ func TestCompletePagedIssueContainersCountsFetchFailures(t *testing.T) { getComments: func(int) (*jira.JiraCommentContainer, error) { return nil, errors.New("down") }, getWorklogs: func(int) (*jira.JiraWorklogContainer, error) { return nil, errors.New("down") }, } - progress := &ImportProgress{} + im := &jiraImportContext{progress: &ImportProgress{}, client: client} handler := &JiraImportHandler{} - _ = handler.completePagedIssueContainers(t.Context(), issue, client, progress) - if progress.FailedComments != 1 { - t.Errorf("FailedComments = %d, want 1", progress.FailedComments) + handler.completeJiraBatchSubresources(t.Context(), im, "APP", []jira.JiraIssue{*issue}, nil) + + if im.progress.FailedComments != 1 { + t.Errorf("FailedComments = %d, want 1", im.progress.FailedComments) } - if progress.FailedWorklogs != 1 { - t.Errorf("FailedWorklogs = %d, want 1", progress.FailedWorklogs) + if im.progress.FailedWorklogs != 1 { + t.Errorf("FailedWorklogs = %d, want 1", im.progress.FailedWorklogs) } } diff --git a/internal/handlers/jira_unsupported_jql_collection_test.go b/internal/handlers/jira_unsupported_jql_collection_test.go new file mode 100644 index 0000000..64ab7e9 --- /dev/null +++ b/internal/handlers/jira_unsupported_jql_collection_test.go @@ -0,0 +1,106 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/jira" +) + +type fakeJiraFilterClient struct { + jira.Client + filters *jira.FilterSearchResult +} + +func (f *fakeJiraFilterClient) ListFilters(context.Context, string) (*jira.FilterSearchResult, error) { + return f.filters, nil +} + +// TestImportJiraSavedFilterWithUnsupportedPredicateIsNonExecutable is the +// WI-2068 regression: an unsupported JQL predicate must not become a +// workspace-wide executable collection. The imported collection stays +// non-executable and is flagged for review. +func TestImportJiraSavedFilterWithUnsupportedPredicateIsNonExecutable(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + h := NewJiraImportHandler(db, "jira-unsupported-jql-secret", "", blobstore.Disabled()) + + var workspaceID int + if err := db.QueryRow(` + INSERT INTO workspaces (name, key, active) VALUES ('Filter Workspace', 'FLT', true) RETURNING id + `).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + + client := &fakeJiraFilterClient{filters: &jira.FilterSearchResult{Values: []jira.JiraFilter{{ + ID: "1", + Name: "My filter", + JQL: `project = FLT AND assignee = currentUser()`, + }}}} + h.importJiraSavedFilters(context.Background(), "job-jsm", "FLT", workspaceID, client, 0) + + var qlQuery string + if err := db.QueryRow(`SELECT ql_query FROM collections WHERE workspace_id = ?`, workspaceID).Scan(&qlQuery); err != nil { + t.Fatalf("load imported collection: %v", err) + } + if !strings.Contains(qlQuery, "1 = 0") { + t.Fatalf("ql_query = %q, want a non-matching selector", qlQuery) + } + if strings.Contains(qlQuery, "assignee") { + t.Fatalf("ql_query = %q, must not execute an untranslatable predicate", qlQuery) + } + + var metadataJSON string + if err := db.QueryRow(` + SELECT metadata_json FROM jira_import_id_mappings + WHERE job_id = 'job-jsm' AND entity_type = 'collection' AND jira_id = 'filter:1' + `).Scan(&metadataJSON); err != nil { + t.Fatalf("load collection mapping: %v", err) + } + var metadata map[string]any + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + t.Fatalf("decode collection mapping metadata: %v", err) + } + if metadata["needs_review"] != true || metadata["unsupported_membership"] != true { + t.Fatalf("collection metadata = %#v, want needs_review/unsupported_membership", metadata) + } + if _, ok := metadata["unsupported_jql"].([]any); !ok { + t.Fatalf("collection metadata = %#v, want unsupported_jql recorded", metadata) + } +} + +// TestImportJiraSavedFilterWithOnlyUnsupportedOrderingStaysExecutable proves +// unsupported ordering does not broaden or disable a complete predicate tree. +func TestImportJiraSavedFilterWithOnlyUnsupportedOrderingStaysExecutable(t *testing.T) { + db := newJiraServiceManagementTestDB(t) + h := NewJiraImportHandler(db, "jira-unsupported-order-secret", "", blobstore.Disabled()) + + var workspaceID int + if err := db.QueryRow(` + INSERT INTO workspaces (name, key, active) VALUES ('Order Workspace', 'ORD', true) RETURNING id + `).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + + client := &fakeJiraFilterClient{filters: &jira.FilterSearchResult{Values: []jira.JiraFilter{{ + ID: "2", + Name: "Ordered filter", + JQL: `project = ORD AND status = Open ORDER BY created DESC`, + }}}} + h.importJiraSavedFilters(context.Background(), "job-jsm", "ORD", workspaceID, client, 0) + + var qlQuery string + if err := db.QueryRow(`SELECT ql_query FROM collections WHERE workspace_id = ?`, workspaceID).Scan(&qlQuery); err != nil { + t.Fatalf("load imported collection: %v", err) + } + if strings.Contains(qlQuery, "1 = 0") { + t.Fatalf("ql_query = %q, want the translated predicate tree, not a disabled selector", qlQuery) + } + if !strings.Contains(qlQuery, `status = "Open"`) { + t.Fatalf("ql_query = %q, want the status predicate preserved", qlQuery) + } +} diff --git a/internal/handlers/jira_workflow_import_test.go b/internal/handlers/jira_workflow_import_test.go index 0cb8b98..785858e 100644 --- a/internal/handlers/jira_workflow_import_test.go +++ b/internal/handlers/jira_workflow_import_test.go @@ -97,6 +97,19 @@ func TestAuthoritativeJiraWorkflowEdgesPreserveTopologyAndOnlyLockKnownRules(t * ActionCount: 2, TriggerCount: 1, }, + { + ID: "self", + Type: jira.JiraWorkflowTransitionDirected, + FromStatusIDs: []string{"doing"}, + ToStatusID: "doing", + }, + { + ID: "guarded-self", + Type: jira.JiraWorkflowTransitionDirected, + FromStatusIDs: []string{"done"}, + ToStatusID: "done", + ValidatorCount: 1, + }, { ID: "global", Type: jira.JiraWorkflowTransitionGlobal, @@ -120,13 +133,15 @@ func TestAuthoritativeJiraWorkflowEdgesPreserveTopologyAndOnlyLockKnownRules(t * if !initialTargets[11] { t.Fatalf("initial targets = %#v, want status 11", initialTargets) } - for _, want := range [][2]int{{11, 22}, {22, 11}, {33, 11}, {22, 33}} { + for _, want := range [][2]int{{11, 22}, {22, 11}, {33, 11}, {22, 33}, {22, 22}, {33, 33}} { if !got[want] { t.Errorf("missing directed edge %v; got %#v", want, got) } } - if got[[2]int{11, 11}] { - t.Fatal("global transition created a redundant self-edge") + // A global transition can be applied from the status it targets, so it now + // imports the same-status edge too. + if !got[[2]int{11, 11}] { + t.Fatal("global transition self-edge was dropped") } locked := make(map[[2]int]bool) for _, edge := range edges { @@ -140,8 +155,13 @@ func TestAuthoritativeJiraWorkflowEdgesPreserveTopologyAndOnlyLockKnownRules(t * if !locked[[2]int{22, 33}] { t.Fatal("explicitly guarded Jira transition was not review locked") } - if group.guardedTransitions != 1 || - group.unsupportedLoops != 1 || + if locked[[2]int{22, 22}] { + t.Fatal("unguarded loop transition was review locked") + } + if !locked[[2]int{33, 33}] { + t.Fatal("guarded loop transition was not review locked") + } + if group.guardedTransitions != 2 || group.unsupportedActions != 2 || group.unsupportedTriggers != 1 { t.Fatalf("fidelity counters = %#v", group) @@ -655,3 +675,85 @@ func TestEnsureWorkflowsPreservesSourceIdentityGraphAndIssueTypeAssignment(t *te ) } } + +func TestEnsureWorkflowsImportsSameStatusLoopTransition(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + insertJiraImportConnection(t, tdb.Database, "connection-loop") + insertJiraImportJob(t, tdb.Database, "job-loop", "connection-loop", "running", []string{"APP"}) + + insertID := func(query string, args ...interface{}) int { + t.Helper() + var id int + if err := tdb.QueryRow(query+" RETURNING id", args...).Scan(&id); err != nil { + t.Fatalf("insert fixture: %v", err) + } + return id + } + categoryID := insertID(`INSERT INTO status_categories (name, color) VALUES ('Jira Loop', '#123456')`) + reviewID := insertID( + `INSERT INTO statuses (name, description, category_id) VALUES ('Jira Loop Review', '', ?)`, + categoryID, + ) + bugID := insertID(`INSERT INTO item_types (name) VALUES ('Jira Loop Bug')`) + workspaceID := insertID(`INSERT INTO workspaces (name, key) VALUES ('Jira Loop Workspace', 'APP')`) + + client := &fakeJiraWorkflowImportClient{ + project: jira.JiraProject{ID: "10000", Key: "APP", Name: "App"}, + issueTypeStatuses: []jira.JiraIssueTypeWithStatuses{ + {ID: "bug", Name: "Bug", Statuses: []jira.JiraStatus{{ID: "review", Name: "Review"}}}, + }, + configuration: &jira.JiraProjectWorkflowConfiguration{ + IssueTypeWorkflowIDs: map[string]string{"bug": "wf-loop"}, + Workflows: []jira.JiraWorkflowConfiguration{ + { + ID: "wf-loop", + Name: "Loop flow", + StatusIDs: []string{"review"}, + Transitions: []jira.JiraConfiguredWorkflowTransition{ + {ID: "loop-init", Type: jira.JiraWorkflowTransitionInitial, ToStatusID: "review"}, + { + ID: "loop-self", + Type: jira.JiraWorkflowTransitionDirected, + FromStatusIDs: []string{"review"}, + ToStatusID: "review", + ValidatorCount: 1, + }, + }, + }, + }, + RulesComplete: true, + }, + } + handler := NewJiraImportHandler(tdb.GetDatabase(), "jira-loop-test-secret-32-bytes", "", blobstore.Disabled()) + if err := handler.ensureWorkflowsAndConfigSet( + context.Background(), + "job-loop", + "APP", + workspaceID, + map[string]int{"review": reviewID}, + map[string]int{"bug": bugID}, + client, + ); err != nil { + t.Fatalf("ensureWorkflowsAndConfigSet() error = %v", err) + } + + var workflowID int + if err := tdb.QueryRow(` + SELECT windshift_id FROM jira_import_id_mappings + WHERE job_id = 'job-loop' AND entity_type = 'workflow' AND jira_id = 'wf-loop' + `).Scan(&workflowID); err != nil { + t.Fatalf("query workflow mapping: %v", err) + } + + var count int + if err := tdb.QueryRow(` + SELECT COUNT(*) FROM workflow_transitions + WHERE workflow_id = ? AND from_status_id = ? AND to_status_id = ? + `, workflowID, reviewID, reviewID).Scan(&count); err != nil { + t.Fatalf("count loop transition: %v", err) + } + if count != 1 { + t.Fatalf("loop transition count = %d, want 1", count) + } +} diff --git a/internal/handlers/jira_worklog_completion_test.go b/internal/handlers/jira_worklog_completion_test.go new file mode 100644 index 0000000..c227f8f --- /dev/null +++ b/internal/handlers/jira_worklog_completion_test.go @@ -0,0 +1,113 @@ +//go:build test + +package handlers + +import ( + "context" + "fmt" + "testing" + + "windshift/internal/jira" +) + +type fakeWorklogClient struct { + jira.Client + page *jira.JiraWorklogContainer + requests []int +} + +func (c *fakeWorklogClient) GetIssueWorklogs(_ context.Context, _ string, startAt, _ int) (*jira.JiraWorklogContainer, error) { + c.requests = append(c.requests, startAt) + return c.page, nil +} + +func worklogRange(first, last int) []jira.JiraWorklog { + worklogs := make([]jira.JiraWorklog, 0, last-first+1) + for id := first; id <= last; id++ { + worklogs = append(worklogs, jira.JiraWorklog{ID: fmt.Sprintf("%d", id)}) + } + return worklogs +} + +// TestCompleteIssueWorklogsDeduplicatesCompleteDataCenterList is the WI-2072 +// regression: Data Center returns the complete worklog list regardless of +// startAt, so merging must not duplicate the rows already embedded on the +// issue. +func TestCompleteIssueWorklogsDeduplicatesCompleteDataCenterList(t *testing.T) { + embedded := worklogRange(1, 20) + complete := worklogRange(1, 30) + client := &fakeWorklogClient{page: &jira.JiraWorklogContainer{ + Worklogs: complete, StartAt: 0, MaxResults: len(complete), Total: len(complete), + }} + issue := &jira.JiraIssue{ + Key: "DC-1", + Fields: jira.JiraIssueFields{ + Worklog: &jira.JiraWorklogContainer{Worklogs: embedded, MaxResults: 20, Total: 30}, + }, + } + + if err := (&JiraImportHandler{}).completeIssueWorklogs(context.Background(), issue, client); err != nil { + t.Fatalf("completeIssueWorklogs: %v", err) + } + + got := issue.Fields.Worklog.Worklogs + if len(got) != 30 { + t.Fatalf("worklog count = %d, want 30 unique rows", len(got)) + } + seen := make(map[string]bool, len(got)) + for _, worklog := range got { + if seen[worklog.ID] { + t.Fatalf("duplicate worklog id %q in %#v", worklog.ID, got) + } + seen[worklog.ID] = true + } + for id := 1; id <= 30; id++ { + if !seen[fmt.Sprintf("%d", id)] { + t.Fatalf("worklog id %d missing from merged result", id) + } + } +} + +// TestCompleteIssueWorklogsKeepsCloudPagination proves the Cloud paged path is +// unchanged: a page starting at the requested offset appends its rows once. +func TestCompleteIssueWorklogsKeepsCloudPagination(t *testing.T) { + embedded := worklogRange(1, 20) + page := worklogRange(21, 50) + client := &fakeWorklogClient{page: &jira.JiraWorklogContainer{ + Worklogs: page, StartAt: 20, MaxResults: 30, Total: 50, + }} + issue := &jira.JiraIssue{ + Key: "CLOUD-1", + Fields: jira.JiraIssueFields{ + Worklog: &jira.JiraWorklogContainer{Worklogs: embedded, MaxResults: 20, Total: 50}, + }, + } + + if err := (&JiraImportHandler{}).completeIssueWorklogs(context.Background(), issue, client); err != nil { + t.Fatalf("completeIssueWorklogs: %v", err) + } + if got := len(issue.Fields.Worklog.Worklogs); got != 50 { + t.Fatalf("worklog count = %d, want 50", got) + } + if len(client.requests) != 1 || client.requests[0] != 20 { + t.Fatalf("worklog requests = %#v, want a single request at startAt 20", client.requests) + } +} + +func TestMergeJiraWorklogsPreservesFirstOccurrence(t *testing.T) { + t.Parallel() + + existing := []jira.JiraWorklog{{ID: "1", TimeSpentSeconds: 60}, {ID: "2", TimeSpentSeconds: 120}} + incoming := []jira.JiraWorklog{{ID: "2", TimeSpentSeconds: 999}, {ID: "3", TimeSpentSeconds: 30}} + merged := mergeJiraWorklogs(existing, incoming) + + if len(merged) != 3 { + t.Fatalf("merged count = %d, want 3", len(merged)) + } + if merged[1].TimeSpentSeconds != 120 { + t.Fatalf("duplicate worklog replaced the first occurrence: %#v", merged) + } + if merged[2].ID != "3" { + t.Fatalf("merged = %#v, want id 3 appended", merged) + } +} diff --git a/internal/handlers/notification_test.go b/internal/handlers/notification_test.go index 30e347c..18d5559 100644 --- a/internal/handlers/notification_test.go +++ b/internal/handlers/notification_test.go @@ -454,6 +454,64 @@ func TestNotificationHandler_ClearNotifications_DeletesOnlyCurrentUsersInbox(t * } } +// --- DeleteNotification --- + +func TestNotificationHandler_DeleteNotification_Unauthenticated(t *testing.T) { + handler, tdb := setupNotificationHandler(t) + defer tdb.Close() + + req := testutils.CreateJSONRequest(t, "DELETE", "/api/notifications/1", nil) + req.SetPathValue("id", "1") + rr := testutils.ExecuteRequest(t, handler.DeleteNotification, req) + rr.AssertStatusCode(http.StatusUnauthorized) +} + +func TestNotificationHandler_DeleteNotification_DeletesOnlyCurrentUsersNotification(t *testing.T) { + handler, tdb := setupNotificationHandler(t) + defer tdb.Close() + otherUserID, err := factory.NewTestFactory(tdb.GetDatabase()).CreateUser(nil) + if err != nil { + t.Fatalf("create other user: %v", err) + } + + own, err := handler.manager.AddNotification(models.Notification{UserID: 1, Title: "Own", Message: "body", Type: "info", AuthorizationScope: models.NotificationScopeSystem}) + if err != nil { + t.Fatalf("seed own notification: %v", err) + } + other, err := handler.manager.AddNotification(models.Notification{UserID: otherUserID, Title: "Other", Message: "body", Type: "info", AuthorizationScope: models.NotificationScopeSystem}) + if err != nil { + t.Fatalf("seed other notification: %v", err) + } + + // A caller cannot delete another user's notification by guessing its id. + req := testutils.CreateJSONRequest(t, "DELETE", "/api/notifications/"+testutils.IntToString(other.ID), nil) + req.SetPathValue("id", testutils.IntToString(other.ID)) + rr := testutils.ExecuteAuthenticatedRequest(t, handler.DeleteNotification, req, nil) + rr.AssertStatusCode(http.StatusNoContent) + + otherNotifications, err := handler.manager.GetUserNotifications(otherUserID, 50, 0) + if err != nil { + t.Fatalf("get other user notifications: %v", err) + } + if len(otherNotifications) != 1 || otherNotifications[0].Title != "Other" { + t.Fatalf("other user notifications = %+v, want the untouched notification", otherNotifications) + } + + // The caller's own notification is deleted and the tray cache is dropped. + req = testutils.CreateJSONRequest(t, "DELETE", "/api/notifications/"+testutils.IntToString(own.ID), nil) + req.SetPathValue("id", testutils.IntToString(own.ID)) + rr = testutils.ExecuteAuthenticatedRequest(t, handler.DeleteNotification, req, nil) + rr.AssertStatusCode(http.StatusNoContent) + + ownNotifications, err := handler.manager.GetUserNotifications(1, 50, 0) + if err != nil { + t.Fatalf("get own notifications: %v", err) + } + if len(ownNotifications) != 0 { + t.Fatalf("own notifications = %d, want 0", len(ownNotifications)) + } +} + // --- CreateNotification --- func TestNotificationHandler_CreateNotification_Success(t *testing.T) { diff --git a/internal/handlers/password_length_test.go b/internal/handlers/password_length_test.go index 2bd7858..5e5692e 100644 --- a/internal/handlers/password_length_test.go +++ b/internal/handlers/password_length_test.go @@ -39,8 +39,7 @@ func newPasswordAuthHandler(t *testing.T, db database.Database) *AuthHandler { } t.Cleanup(func() { _ = permissionService.Close() }) - rateLimiter := middleware.NewRateLimiter(100, 100, false, nil) - t.Cleanup(rateLimiter.Stop) + loginLockout := middleware.NewLoginLockout() sessionManager := auth.NewSessionManagerWithValidationCacheTTL( db, false, false, nil, "password-length-test-secret", "strict", auth.DefaultSessionValidationCacheTTL, @@ -50,7 +49,7 @@ func newPasswordAuthHandler(t *testing.T, db database.Database) *AuthHandler { repository.NewCredentialRepository(db), logger.NewAuditor(db), sessionManager, - rateLimiter, + loginLockout, permissionService, nil, utils.NewIPExtractor(false, nil), diff --git a/internal/handlers/plugin_asset_auth_test.go b/internal/handlers/plugin_asset_auth_test.go new file mode 100644 index 0000000..2cf42ef --- /dev/null +++ b/internal/handlers/plugin_asset_auth_test.go @@ -0,0 +1,162 @@ +//go:build test && !noplugins + +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "windshift/internal/plugins" +) + +// allowAllAssets lets the existing inline-serving test exercise the asset +// writer without an auth policy. +func allowAllAssets(next http.Handler) http.Handler { return next } + +func requireUserAssets(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Test-User") == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func requireAdminAssets(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Test-User") != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func limitPublicAssets(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Public-Limit", "applied") + next.ServeHTTP(w, r) + }) +} + +// newPluginManagerWithAssetPolicy loads a plugin whose manifest declares a +// distinct auth policy per asset prefix, plus one undeclared asset. +func newPluginManagerWithAssetPolicy(t *testing.T) *plugins.Manager { + t.Helper() + + root := t.TempDir() + pluginDir := filepath.Join(root, "test-plugin") + assetsDir := filepath.Join(pluginDir, "assets") + for _, dir := range []string{ + filepath.Join(assetsDir, "public"), + filepath.Join(assetsDir, "admin"), + filepath.Join(assetsDir, "internal"), + } { + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create asset dir %s: %v", dir, err) + } + } + + manifest := []byte(`{ + "name": "test-plugin", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "assets": [ + {"path": "public/*", "auth": "none"}, + {"path": "admin/*", "auth": "admin"}, + {"path": "internal/*", "auth": "session"} + ] + }`) + if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), manifest, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), minimalPluginModule(), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + for _, rel := range []string{"public/app.js", "admin/config.html", "internal/secret.js", "top.html"} { + if err := os.WriteFile(filepath.Join(assetsDir, rel), []byte("body:"+rel), 0o600); err != nil { + t.Fatalf("write asset %s: %v", rel, err) + } + } + + manager := plugins.NewManager(root, plugins.WithAllowPublicRoutes(true)) + if err := manager.LoadPlugins(); err != nil { + t.Fatalf("load plugins: %v", err) + } + return manager +} + +func TestGetAssetEnforcesDeclaredAuth(t *testing.T) { + manager := newPluginManagerWithAssetPolicy(t) + t.Cleanup(func() { _ = manager.Close() }) + + handler := NewPluginHandler(manager, nil, nil, false) + handler.SetAssetAuth(requireUserAssets, requireAdminAssets, limitPublicAssets) + + cases := []struct { + name string + asset string + user string + want int + wantPublicLimit bool + }{ + {name: "public asset allows anonymous", asset: "public/app.js", want: http.StatusOK, wantPublicLimit: true}, + {name: "session asset rejects anonymous", asset: "internal/secret.js", want: http.StatusUnauthorized}, + {name: "session asset allows a user", asset: "internal/secret.js", user: "3", want: http.StatusOK}, + {name: "admin asset rejects a non-admin", asset: "admin/config.html", user: "3", want: http.StatusForbidden}, + {name: "admin asset allows an admin", asset: "admin/config.html", user: "admin", want: http.StatusOK}, + {name: "undeclared asset defaults to session", asset: "top.html", want: http.StatusUnauthorized}, + {name: "undeclared asset allows a user", asset: "top.html", user: "3", want: http.StatusOK}, + {name: "missing asset is not found for an authorized user", asset: "internal/missing.js", user: "3", want: http.StatusNotFound}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/plugins/test-plugin/assets/"+tc.asset, nil) + req.SetPathValue("name", "test-plugin") + req.SetPathValue("asset", tc.asset) + if tc.user != "" { + req.Header.Set("X-Test-User", tc.user) + } + rec := httptest.NewRecorder() + + handler.GetAsset(rec, req) + + if rec.Code != tc.want { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, tc.want, rec.Body.String()) + } + gotLimit := rec.Header().Get("X-Public-Limit") + if tc.wantPublicLimit && gotLimit != "applied" { + t.Errorf("public limiter not applied: X-Public-Limit = %q", gotLimit) + } + if !tc.wantPublicLimit && gotLimit != "" { + t.Errorf("public limiter applied to a non-public asset: %q", gotLimit) + } + if tc.want == http.StatusOK && rec.Header().Get("X-Frame-Options") != "SAMEORIGIN" { + t.Errorf("X-Frame-Options = %q, want SAMEORIGIN", rec.Header().Get("X-Frame-Options")) + } + }) + } +} + +func TestGetAssetFailsClosedWithoutAuthWiring(t *testing.T) { + manager := newPluginManagerWithAssetPolicy(t) + t.Cleanup(func() { _ = manager.Close() }) + + handler := NewPluginHandler(manager, nil, nil, false) + + req := httptest.NewRequest(http.MethodGet, "/api/plugins/test-plugin/assets/internal/secret.js", nil) + req.SetPathValue("name", "test-plugin") + req.SetPathValue("asset", "internal/secret.js") + rec := httptest.NewRecorder() + + handler.GetAsset(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 when auth middleware is not configured", rec.Code) + } +} diff --git a/internal/handlers/plugin_asset_test.go b/internal/handlers/plugin_asset_test.go index 840657a..e75fb08 100644 --- a/internal/handlers/plugin_asset_test.go +++ b/internal/handlers/plugin_asset_test.go @@ -33,6 +33,7 @@ func TestGetAssetServesFrontendInline(t *testing.T) { }() handler := NewPluginHandler(manager, nil, nil, false) + handler.SetAssetAuth(allowAllAssets, allowAllAssets, nil) tests := []struct { name string diff --git a/internal/handlers/plugin_list_permissions_test.go b/internal/handlers/plugin_list_permissions_test.go new file mode 100644 index 0000000..494abab --- /dev/null +++ b/internal/handlers/plugin_list_permissions_test.go @@ -0,0 +1,73 @@ +//go:build test && !noplugins + +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "windshift/internal/plugins" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +// TestListPluginsReportsRequestedAndEffectivePermissions guards the admin +// surface: an operator must be able to see what a plugin asked for and what it +// actually holds after license gating. +func TestListPluginsReportsRequestedAndEffectivePermissions(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + + root := t.TempDir() + pluginDir := filepath.Join(root, "permission-plugin") + if err := os.Mkdir(pluginDir, 0o750); err != nil { + t.Fatalf("create plugin directory: %v", err) + } + manifest := []byte(`{ + "name": "permission-plugin", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "permissions": ["process.exec"] +}`) + if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), manifest, 0o600); err != nil { + t.Fatalf("write plugin manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), minimalPluginModule(), 0o600); err != nil { + t.Fatalf("write plugin WASM: %v", err) + } + + manager := plugins.NewManager(root) + if err := manager.LoadPlugins(); err != nil { + t.Fatalf("load plugins: %v", err) + } + defer func() { _ = manager.Close() }() + + handler := NewPluginHandler(manager, repository.NewPluginRegistryRepository(tdb.Database), nil, false) + + req := httptest.NewRequest(http.MethodGet, "/admin/plugins", nil) + rec := httptest.NewRecorder() + handler.ListPlugins(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var infos []PluginInfo + if err := json.Unmarshal(rec.Body.Bytes(), &infos); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(infos) != 1 { + t.Fatalf("plugin count = %d, want 1", len(infos)) + } + got := infos[0] + if len(got.RequestedPermissions) != 1 || got.RequestedPermissions[0] != plugins.PermissionProcessExec { + t.Fatalf("requested permissions = %v, want [%s]", got.RequestedPermissions, plugins.PermissionProcessExec) + } + if len(got.EffectivePermissions) != 1 || got.EffectivePermissions[0] != plugins.PermissionProcessExec { + t.Fatalf("effective permissions = %v, want [%s]", got.EffectivePermissions, plugins.PermissionProcessExec) + } +} diff --git a/internal/handlers/portal_asset_access_test.go b/internal/handlers/portal_asset_access_test.go index 63d076a..e51c773 100644 --- a/internal/handlers/portal_asset_access_test.go +++ b/internal/handlers/portal_asset_access_test.go @@ -23,6 +23,7 @@ type portalAssetFixture struct { db database.Database channelID int setID int + reportID int inCQLID int outCQLID int } @@ -55,17 +56,17 @@ func seedPortalAssetFixture(t *testing.T, cql string) portalAssetFixture { assetTypeID := insert("asset type", "INSERT INTO asset_types (set_id, name) VALUES (?, 'Device') RETURNING id", setID) inCQLID := insert("matching asset", "INSERT INTO assets (set_id, asset_type_id, title, asset_tag) VALUES (?, ?, 'Matching device', 'MATCH-1') RETURNING id", setID, assetTypeID) outCQLID := insert("hidden asset", "INSERT INTO assets (set_id, asset_type_id, title, asset_tag) VALUES (?, ?, 'Hidden device', 'HIDDEN-1') RETURNING id", setID, assetTypeID) - insert("asset report", ` + reportID := insert("asset report", ` INSERT INTO asset_reports (channel_id, asset_set_id, name, cql_query, is_active, column_config, run_mode) VALUES (?, ?, 'Inventory', ?, true, '["title","asset_tag"]', 'direct') RETURNING id `, channelID, setID, cql) - sessionManager := auth.NewSessionManagerWithValidationCacheTTL(db, false, false, nil, "portal-asset-test-secret", "strict", auth.DefaultSessionValidationCacheTTL) return portalAssetFixture{ handler: NewPortalHandler(db, sessionManager, nil, nil, blobstore.Disabled()), db: db, channelID: channelID, setID: setID, + reportID: reportID, inCQLID: inCQLID, outCQLID: outCQLID, } @@ -211,3 +212,66 @@ func TestSearchPortalAssetsEmptyQueryReturnsNothing(t *testing.T) { t.Fatalf("body = %s, want empty asset list", recorder.Body.String()) } } + +// TestGetAssetReportsRequiresPortalEnabledSet pins the set-level exposure +// switch on the report catalog: a report over an asset set without a portal +// grant must not be listed. +func TestGetAssetReportsRequiresPortalEnabledSet(t *testing.T) { + f := seedPortalAssetFixture(t, "") + + recorder := httptest.NewRecorder() + f.handler.GetAssetReports(recorder, f.request(t, http.MethodGet, "/api/portal/asset-portal/asset-reports", "", 0)) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var reports []map[string]any + if err := json.NewDecoder(recorder.Body).Decode(&reports); err != nil { + t.Fatalf("decode reports: %v", err) + } + if len(reports) != 0 { + t.Fatalf("reports = %+v, want none without a portal grant", reports) + } +} + +// TestExecuteAssetReportRequiresPortalEnabledSet pins the same switch on the +// execute path: a direct report id must not bypass a revoked set grant. +func TestExecuteAssetReportRequiresPortalEnabledSet(t *testing.T) { + f := seedPortalAssetFixture(t, "") + + req := f.request(t, http.MethodGet, "/api/portal/asset-portal/asset-reports/1/execute", "", 0) + req.SetPathValue("id", fmt.Sprintf("%d", f.reportID)) + recorder := httptest.NewRecorder() + f.handler.ExecuteAssetReport(recorder, req) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 without a portal grant; body=%s", recorder.Code, recorder.Body.String()) + } +} + +// TestExecuteAssetReportWithPortalGrantReturnsReportAssets is the positive +// half: a granted set keeps its report executable. +func TestExecuteAssetReportWithPortalGrantReturnsReportAssets(t *testing.T) { + f := seedPortalAssetFixture(t, `asset_tag = "MATCH-1"`) + f.grantPortalAccess(t) + + req := f.request(t, http.MethodGet, "/api/portal/asset-portal/asset-reports/1/execute", "", 0) + req.SetPathValue("id", fmt.Sprintf("%d", f.reportID)) + recorder := httptest.NewRecorder() + f.handler.ExecuteAssetReport(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var response struct { + Assets []struct { + ID int `json:"id"` + } `json:"assets"` + } + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode execute response: %v", err) + } + if len(response.Assets) != 1 || response.Assets[0].ID != f.inCQLID { + t.Fatalf("assets = %+v, want only asset %d", response.Assets, f.inCQLID) + } +} diff --git a/internal/handlers/portal_asset_report_execution_test.go b/internal/handlers/portal_asset_report_execution_test.go index f7a6a1b..b364832 100644 --- a/internal/handlers/portal_asset_report_execution_test.go +++ b/internal/handlers/portal_asset_report_execution_test.go @@ -39,6 +39,9 @@ func TestExecuteAssetReportSubstitutesFormPlaceholderBeforeRunningCQL(t *testing VALUES ('Asset report portal', 'portal', 'inbound', 'enabled', ?, 'asset-report') RETURNING id `, channelConfig) assetSetID := insertID("asset set", `INSERT INTO asset_management_sets (name) VALUES ('Asset report inventory') RETURNING id`) + if _, err := db.ExecWrite(`INSERT INTO asset_set_portal_access (set_id) VALUES (?)`, assetSetID); err != nil { + t.Fatalf("grant portal access: %v", err) + } assetTypeID := insertID("asset type", `INSERT INTO asset_types (set_id, name) VALUES (?, 'Device') RETURNING id`, assetSetID) wantedAssetID := insertID("matching asset", ` INSERT INTO assets (set_id, asset_type_id, title, asset_tag) diff --git a/internal/handlers/portal_asset_row_actions_test.go b/internal/handlers/portal_asset_row_actions_test.go index 39d4a92..4ef9cdb 100644 --- a/internal/handlers/portal_asset_row_actions_test.go +++ b/internal/handlers/portal_asset_row_actions_test.go @@ -61,6 +61,9 @@ func TestPortalBootstrapRowActionsExposeOnlyVisibleRequestTypes(t *testing.T) { if err := db.QueryRow(`INSERT INTO asset_management_sets (name) VALUES ('Row action set') RETURNING id`).Scan(&assetSetID); err != nil { t.Fatalf("insert asset set: %v", err) } + if _, err := db.ExecWrite(`INSERT INTO asset_set_portal_access (set_id) VALUES (?)`, assetSetID); err != nil { + t.Fatalf("grant portal access: %v", err) + } if _, err := db.ExecWrite(` INSERT INTO asset_reports (channel_id, asset_set_id, name, cql_query, run_mode, config) VALUES (?, ?, 'Devices', 'status = "Active"', 'direct', ?) diff --git a/internal/handlers/portal_bootstrap_test.go b/internal/handlers/portal_bootstrap_test.go index 23c3d3e..afdd38f 100644 --- a/internal/handlers/portal_bootstrap_test.go +++ b/internal/handlers/portal_bootstrap_test.go @@ -93,6 +93,9 @@ func TestAuthenticatedPortalBootstrapAssetReportsUseExactSafeContract(t *testing if err := db.QueryRow(`INSERT INTO asset_management_sets (name) VALUES ('Internal inventory') RETURNING id`).Scan(&assetSetID); err != nil { t.Fatalf("insert asset set: %v", err) } + if _, err := db.ExecWrite(`INSERT INTO asset_set_portal_access (set_id) VALUES (?)`, assetSetID); err != nil { + t.Fatalf("grant portal access: %v", err) + } var reportID int if err := db.QueryRow(` INSERT INTO asset_reports diff --git a/internal/handlers/portal_visibility_drafts_test.go b/internal/handlers/portal_visibility_drafts_test.go index add0d16..87a1249 100644 --- a/internal/handlers/portal_visibility_drafts_test.go +++ b/internal/handlers/portal_visibility_drafts_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "windshift/internal/auth" @@ -16,6 +17,7 @@ import ( "windshift/internal/middleware" "windshift/internal/models" "windshift/internal/repository" + "windshift/internal/services" "windshift/internal/testutils" ) @@ -27,6 +29,7 @@ type portalVisibilityDraftFixture struct { visibleRequestTypeID int hiddenRequestTypeID int inactiveRequestTypeID int + requestItemID int } func newPortalVisibilityDraftFixture(t *testing.T) *portalVisibilityDraftFixture { @@ -79,6 +82,23 @@ func newPortalVisibilityDraftFixture(t *testing.T) *portalVisibilityDraftFixture hiddenID := insertRequestType("Hidden", true, `[999999]`) inactiveID := insertRequestType("Inactive", false, nil) + // A portal request whose description carries a page link; the portal must + // degrade it when the page is not published through this portal. + var requestItemID int + if err := db.QueryRow(` + INSERT INTO items (workspace_id, workspace_item_number, title, description, channel_id, creator_id, frac_index) + VALUES (?, 1, 'Request with page link', 'See [secret](page:424242) and @{Agent Smith#777} for details', ?, ?, 'a0') + RETURNING id + `, workspaceID, channelID, userID).Scan(&requestItemID); err != nil { + t.Fatalf("insert portal request: %v", err) + } + if _, err := db.ExecWrite(` + INSERT INTO comments (item_id, author_id, content, is_private) + VALUES (?, ?, 'Replying about @{Agent Smith#777} and [doc](page:424242)', false) + `, requestItemID, userID); err != nil { + t.Fatalf("insert portal comment: %v", err) + } + repo := repository.NewPortalDraftRepository(db) identity := repository.DraftIdentity{UserID: &userID} for _, requestTypeID := range []int{visibleID, hiddenID, inactiveID} { @@ -93,6 +113,7 @@ func newPortalVisibilityDraftFixture(t *testing.T) *portalVisibilityDraftFixture db: db, handler: NewPortalHandler(db, nil, nil, nil, blobstore.Disabled()), userID: userID, channelID: channelID, visibleRequestTypeID: visibleID, hiddenRequestTypeID: hiddenID, inactiveRequestTypeID: inactiveID, + requestItemID: requestItemID, } } @@ -237,3 +258,150 @@ func TestPortalDraftOwnerCanDeleteRevokedDraftsWithinChannel(t *testing.T) { } } } + +// TestPortalMyRequestsRewritesPageLinks pins the list/detail parity contract: +// GetRequestDetail rewrites description page links and strips embedded mention +// ids, and GetMyRequests feeds the same detail view, so it must apply the same +// rewriting. +func TestPortalMyRequestsRewritesPageLinks(t *testing.T) { + fixture := newPortalVisibilityDraftFixture(t) + request := fixture.request(http.MethodGet, "/api/portal/visibility/my-requests", nil) + recorder := httptest.NewRecorder() + + fixture.handler.GetMyRequests(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var requests []services.PortalRequestSummary + if err := json.NewDecoder(recorder.Body).Decode(&requests); err != nil { + t.Fatalf("decode requests: %v", err) + } + var found bool + for _, summary := range requests { + if summary.ID != fixture.requestItemID { + continue + } + found = true + if strings.Contains(summary.Description, "](page:") { + t.Fatalf("description leaked a page anchor: %q", summary.Description) + } + if strings.Contains(summary.Description, "#777}") { + t.Fatalf("description leaked a mention user id: %q", summary.Description) + } + if !strings.Contains(summary.Description, "secret") || !strings.Contains(summary.Description, "Agent Smith") { + t.Fatalf("description = %q, want link and mention text preserved", summary.Description) + } + } + if !found { + t.Fatalf("request %d missing from %+v", fixture.requestItemID, requests) + } +} + +// TestPortalRequestAttachmentsUnavailableWithoutStorage pins the disabled +// attachment surface: when no storage backend is configured the portal +// attachment service is nil, and the list/download handlers must answer 503 +// instead of dereferencing it. +func TestPortalRequestAttachmentsUnavailableWithoutStorage(t *testing.T) { + fixture := newPortalVisibilityDraftFixture(t) + + listRequest := fixture.request(http.MethodGet, fmt.Sprintf("/api/portal/visibility/requests/%d/attachments", fixture.requestItemID), nil) + listRequest.SetPathValue("itemId", fmt.Sprintf("%d", fixture.requestItemID)) + listRecorder := httptest.NewRecorder() + fixture.handler.GetRequestAttachments(listRecorder, listRequest) + if listRecorder.Code != http.StatusServiceUnavailable { + t.Fatalf("list status = %d, want 503; body=%s", listRecorder.Code, listRecorder.Body.String()) + } + + downloadRequest := fixture.request(http.MethodGet, fmt.Sprintf("/api/portal/visibility/requests/%d/attachments/1/download", fixture.requestItemID), nil) + downloadRequest.SetPathValue("itemId", fmt.Sprintf("%d", fixture.requestItemID)) + downloadRequest.SetPathValue("attachmentId", "1") + downloadRecorder := httptest.NewRecorder() + fixture.handler.DownloadRequestAttachment(downloadRecorder, downloadRequest) + if downloadRecorder.Code != http.StatusServiceUnavailable { + t.Fatalf("download status = %d, want 503; body=%s", downloadRecorder.Code, downloadRecorder.Body.String()) + } +} + +// TestPortalRequestTypesDoNotExposeVisibilityPrincipals pins the public +// contract for the request-type catalog: a portal customer (or any portal +// caller) must not receive the internal group ids / customer-org ids that gate +// visibility. Those are internal routing data, matching PublicAssetReport. +func TestPortalRequestTypesDoNotExposeVisibilityPrincipals(t *testing.T) { + fixture := newPortalVisibilityDraftFixture(t) + + var groupID int + if err := fixture.db.QueryRow(`INSERT INTO groups (name, is_active) VALUES ('Portal visibility group', true) RETURNING id`).Scan(&groupID); err != nil { + t.Fatalf("insert group: %v", err) + } + if _, err := fixture.db.ExecWrite(`INSERT INTO group_members (group_id, user_id) VALUES (?, ?)`, groupID, fixture.userID); err != nil { + t.Fatalf("insert group member: %v", err) + } + var itemTypeID, workspaceID int + if err := fixture.db.QueryRow(`SELECT item_type_id, workspace_id FROM request_types WHERE id = ?`, fixture.visibleRequestTypeID).Scan(&itemTypeID, &workspaceID); err != nil { + t.Fatalf("load visible request type: %v", err) + } + var requestTypeID int + if err := fixture.db.QueryRow(` + INSERT INTO request_types (channel_id, name, item_type_id, workspace_id, is_active, visibility_group_ids) + VALUES (?, 'Group visible', ?, ?, true, ?) RETURNING id + `, fixture.channelID, itemTypeID, workspaceID, fmt.Sprintf("[%d]", groupID)).Scan(&requestTypeID); err != nil { + t.Fatalf("insert group-visible request type: %v", err) + } + + request := fixture.request(http.MethodGet, "/api/portal/visibility/request-types", nil) + recorder := httptest.NewRecorder() + fixture.handler.GetRequestTypes(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var requestTypes []models.RequestType + if err := json.NewDecoder(recorder.Body).Decode(&requestTypes); err != nil { + t.Fatalf("decode request types: %v", err) + } + var found bool + for _, rt := range requestTypes { + if rt.ID != requestTypeID { + continue + } + found = true + if len(rt.VisibilityGroupIDs) != 0 || len(rt.VisibilityOrgIDs) != 0 { + t.Fatalf("request type exposed visibility principals: groups=%v orgs=%v", rt.VisibilityGroupIDs, rt.VisibilityOrgIDs) + } + } + if !found { + t.Fatalf("group-visible request type %d missing from %+v", requestTypeID, requestTypes) + } +} + +// TestPortalRequestCommentsStripMentionIDs pins the comment-side external +// divide: an agent's mention token carries the internal user id, which portal +// customers must never receive in the raw markdown they render. +func TestPortalRequestCommentsStripMentionIDs(t *testing.T) { + fixture := newPortalVisibilityDraftFixture(t) + request := fixture.request(http.MethodGet, fmt.Sprintf("/api/portal/visibility/requests/%d/comments", fixture.requestItemID), nil) + request.SetPathValue("itemId", fmt.Sprintf("%d", fixture.requestItemID)) + recorder := httptest.NewRecorder() + + fixture.handler.GetRequestComments(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var comments []services.PortalComment + if err := json.NewDecoder(recorder.Body).Decode(&comments); err != nil { + t.Fatalf("decode comments: %v", err) + } + if len(comments) != 1 { + t.Fatalf("comments = %+v, want exactly one", comments) + } + if strings.Contains(comments[0].Content, "#777}") { + t.Fatalf("comment leaked a mention user id: %q", comments[0].Content) + } + if strings.Contains(comments[0].Content, "](page:") { + t.Fatalf("comment leaked a page anchor: %q", comments[0].Content) + } + if !strings.Contains(comments[0].Content, "Agent Smith") { + t.Fatalf("comment = %q, want the mention text preserved", comments[0].Content) + } +} diff --git a/internal/handlers/runner_broker_llmproxy_test.go b/internal/handlers/runner_broker_llmproxy_test.go index 7344df3..54a8ef9 100644 --- a/internal/handlers/runner_broker_llmproxy_test.go +++ b/internal/handlers/runner_broker_llmproxy_test.go @@ -36,7 +36,7 @@ func newLLMBrokerFixture(t *testing.T, upstreamURL string, providerConfig ...str config = providerConfig[0] } return newLLMBrokerFixtureFor(t, llm.CreateConnectionRequest{ - Name: "Anthropic broker", ProviderType: "anthropic", Model: "claude-haiku-4-5-20251001", + Name: "Anthropic broker", ProviderType: "anthropic", Model: "claude-sonnet-5", APIKey: "provider-secret", BaseURL: upstreamURL, ProviderConfig: config, IsEnabled: true, }) } @@ -124,7 +124,7 @@ func TestNeutralInferenceEndpointResolvesBindingAndMetersTypedUsage(t *testing.T t.Fatalf("decode upstream: %v", err) } writeAnthropicSSE(w, - anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":3,"cache_read_input_tokens":4,"output_tokens":0}}}`), + anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":3,"cache_read_input_tokens":4,"output_tokens":0}}}`), anthropicEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), anthropicEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ready"}}`), anthropicEvent("content_block_stop", `{"type":"content_block_stop","index":0}`), @@ -139,10 +139,10 @@ func TestNeutralInferenceEndpointResolvesBindingAndMetersTypedUsage(t *testing.T if recorder.Code != http.StatusOK { t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) } - if got := string(upstream["model"]); got != `"claude-haiku-4-5-20251001"` { + if got := string(upstream["model"]); got != `"claude-sonnet-5"` { t.Fatalf("server-authoritative model = %s", got) } - if got := string(upstream["max_tokens"]); got != "64000" { + if got := string(upstream["max_tokens"]); got != "128000" { t.Fatalf("server-capped max_tokens = %s", got) } totals, err := fixture.usage.TotalsForRun(context.Background(), fixture.runID) @@ -251,7 +251,7 @@ func TestNeutralInferenceEndpointPreservesContinuationWithBindingFingerprint(t * upstreamBodies = append(upstreamBodies, upstream) if len(upstreamBodies) == 2 { writeAnthropicSSE(w, - anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[],"stop_reason":null,"usage":{"input_tokens":2,"cache_read_input_tokens":20,"output_tokens":0}}}`), + anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"stop_reason":null,"usage":{"input_tokens":2,"cache_read_input_tokens":20,"output_tokens":0}}}`), anthropicEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), anthropicEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}`), anthropicEvent("content_block_stop", `{"type":"content_block_stop","index":0}`), @@ -261,7 +261,7 @@ func TestNeutralInferenceEndpointPreservesContinuationWithBindingFingerprint(t * return } writeAnthropicSSE(w, - anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":20,"output_tokens":0}}}`), + anthropicEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":20,"output_tokens":0}}}`), anthropicEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), anthropicEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"inspect"}}`), anthropicEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"opaque-signature"}}`), @@ -284,7 +284,7 @@ func TestNeutralInferenceEndpointPreservesContinuationWithBindingFingerprint(t * t.Fatal(err) } message := response.Choices[0].Message - if len(message.ProviderState) == 0 || message.ProviderBinding != llmBindingFingerprint(fixture.connection, "anthropic", "claude-haiku-4-5-20251001") { + if len(message.ProviderState) == 0 || message.ProviderBinding != llmBindingFingerprint(fixture.connection, "anthropic", "claude-sonnet-5") { t.Fatalf("continuation state/binding = %s / %q", message.ProviderState, message.ProviderBinding) } if countJSONKey(upstreamBodies[0], "cache_control") != 3 { @@ -294,6 +294,18 @@ func TestNeutralInferenceEndpointPreservesContinuationWithBindingFingerprint(t * if thinking["type"] != "enabled" || thinking["budget_tokens"] != float64(2048) { t.Fatalf("explicit thinking config = %#v", thinking) } + // The harness supplies its own tool schemas, which never touch the aitools + // registry. The broker must still hand the provider the portable shape a + // strict validator expects, exactly as in-product chat does. + tools, _ := upstreamBodies[0]["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("upstream tools = %#v", upstreamBodies[0]["tools"]) + } + tool, _ := tools[0].(map[string]any) + inputSchema, _ := tool["input_schema"].(map[string]any) + if _, ok := inputSchema["properties"]; !ok { + t.Fatalf("runner tool schema reached provider without properties: %#v", inputSchema) + } secondRequest := llm.CompletionRequest{Messages: []llm.Message{ {Role: "system", Content: "You are a coding agent."}, diff --git a/internal/handlers/shell_bootstrap_test.go b/internal/handlers/shell_bootstrap_test.go index f4a2c36..8710514 100644 --- a/internal/handlers/shell_bootstrap_test.go +++ b/internal/handlers/shell_bootstrap_test.go @@ -54,7 +54,7 @@ func TestShellBootstrapRequiresAuthentication(t *testing.T) { func TestShellBootstrapComposesFeatureSnapshot(t *testing.T) { channels := &stubChannelManagementCapability{manages: true} handler := NewShellBootstrapHandler( - NewFeaturesHandler(nil, true, true), + NewFeaturesHandler(nil, true), nil, nil, nil, @@ -76,7 +76,7 @@ func TestShellBootstrapComposesFeatureSnapshot(t *testing.T) { if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if !response.Features.SSHAvailable || !response.Features.LogbookAvailable { + if !response.Features.SSHAvailable { t.Fatalf("feature snapshot = %+v", response.Features) } if response.AttachmentStatus == nil || response.AttachmentStatus.Enabled { diff --git a/internal/handlers/theme_branding_test.go b/internal/handlers/theme_branding_test.go new file mode 100644 index 0000000..5704814 --- /dev/null +++ b/internal/handlers/theme_branding_test.go @@ -0,0 +1,143 @@ +//go:build test + +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "windshift/internal/models" + "windshift/internal/testutils" +) + +// createBrandedThemeRequest builds a POST /themes request carrying the brand +// layout and full-logo fields. Empty values are sent as-is so tests can cover +// the "omitted"/empty contract. +func createBrandedThemeRequest(t *testing.T, brandLayout, logoFullURL, logoFullURLDark string) *http.Request { + t.Helper() + payload := map[string]any{ + "name": "Acme", + "description": "Company theme", + "nav_background_color_light": "#ffffff", + "nav_text_color_light": "#374151", + "nav_background_color_dark": "#1f2937", + "nav_text_color_dark": "#f3f4f6", + "logo_url": "", + "logo_url_dark": "", + "logo_full_url": logoFullURL, + "logo_full_url_dark": logoFullURLDark, + } + if brandLayout != "" { + payload["brand_layout"] = brandLayout + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return httptest.NewRequest(http.MethodPost, "/api/themes", bytes.NewReader(body)) +} + +func storedThemeColumn(t *testing.T, tdb *testutils.TestDB, themeID int, column string) string { + t.Helper() + var stored string + if err := tdb.Database.QueryRow(`SELECT COALESCE(`+column+`, '') FROM themes WHERE id = ?`, themeID).Scan(&stored); err != nil { + t.Fatalf("read stored %s: %v", column, err) + } + return stored +} + +func TestCreateTheme_PersistsBrandFields(t *testing.T) { + handler, tdb := newThemeLogoHandler(t) + + rec := httptest.NewRecorder() + handler.CreateTheme(rec, createBrandedThemeRequest( + t, models.BrandLayoutFullLogo, "/api/portal-assets/11", "/api/portal-assets/12", + )) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + + var created models.Theme + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode created theme: %v", err) + } + if created.BrandLayout != models.BrandLayoutFullLogo { + t.Fatalf("created brand_layout = %q, want %q", created.BrandLayout, models.BrandLayoutFullLogo) + } + if created.LogoFullURL != "/api/portal-assets/11" || created.LogoFullURLDark != "/api/portal-assets/12" { + t.Fatalf("created full logos = %q / %q", created.LogoFullURL, created.LogoFullURLDark) + } + for column, want := range map[string]string{ + "brand_layout": models.BrandLayoutFullLogo, + "logo_full_url": "/api/portal-assets/11", + "logo_full_url_dark": "/api/portal-assets/12", + } { + if stored := storedThemeColumn(t, tdb, created.ID, column); stored != want { + t.Fatalf("stored %s = %q, want %q", column, stored, want) + } + } +} + +func TestCreateTheme_DefaultsBrandLayoutToMarkText(t *testing.T) { + handler, tdb := newThemeLogoHandler(t) + + rec := httptest.NewRecorder() + handler.CreateTheme(rec, createBrandedThemeRequest(t, "", "", "")) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + + var created models.Theme + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode created theme: %v", err) + } + if created.BrandLayout != models.BrandLayoutMarkText { + t.Fatalf("created brand_layout = %q, want %q", created.BrandLayout, models.BrandLayoutMarkText) + } + if stored := storedThemeColumn(t, tdb, created.ID, "brand_layout"); stored != models.BrandLayoutMarkText { + t.Fatalf("stored brand_layout = %q, want %q", stored, models.BrandLayoutMarkText) + } +} + +func TestCreateTheme_RejectsInvalidBrandLayout(t *testing.T) { + handler, _ := newThemeLogoHandler(t) + + rec := httptest.NewRecorder() + handler.CreateTheme(rec, createBrandedThemeRequest(t, "sideways", "", "")) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Brand layout") { + t.Fatalf("rejection body %q does not name the brand layout field", rec.Body.String()) + } +} + +func TestCreateTheme_RejectsInvalidFullLogoURL(t *testing.T) { + cases := []struct { + name string + url string + }{ + {name: "javascript scheme", url: "javascript:alert(1)"}, + {name: "protocol-relative", url: "//evil.example.com/logo.png"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler, _ := newThemeLogoHandler(t) + + rec := httptest.NewRecorder() + handler.CreateTheme(rec, createBrandedThemeRequest(t, models.BrandLayoutFullLogo, tc.url, "")) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Full logo URL") { + t.Fatalf("rejection body %q does not name the full logo URL field", rec.Body.String()) + } + }) + } +} diff --git a/internal/handlers/time_customers_field_error_test.go b/internal/handlers/time_customers_field_error_test.go new file mode 100644 index 0000000..efe7293 --- /dev/null +++ b/internal/handlers/time_customers_field_error_test.go @@ -0,0 +1,50 @@ +//go:build test + +package handlers + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "windshift/internal/customfields" + "windshift/internal/validation" +) + +// TestRespondCustomerFieldError locks the mapping that a validation failure +// must write a response: known custom-field errors are 400s, anything else is +// a 500 so an unexpected failure never lets an unvalidated write proceed. +func TestRespondCustomerFieldError(t *testing.T) { + cases := []struct { + name string + err error + wantStatus int + }{ + { + name: "validation error is a 400", + err: &validation.ValidationError{Field: "custom_field_values.1", Message: "bad value"}, + wantStatus: http.StatusBadRequest, + }, + { + name: "ambiguous name is a 400", + err: &customfields.AmbiguousNameError{Name: "Priority"}, + wantStatus: http.StatusBadRequest, + }, + { + name: "unexpected error is a 500", + err: errors.New("database exploded"), + wantStatus: http.StatusInternalServerError, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/customer-organisations", nil) + respondCustomerFieldError(recorder, request, tc.err) + if recorder.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, tc.wantStatus) + } + }) + } +} diff --git a/internal/i18n/date_test.go b/internal/i18n/date_test.go new file mode 100644 index 0000000..4008116 --- /dev/null +++ b/internal/i18n/date_test.go @@ -0,0 +1,127 @@ +//go:build test + +package i18n + +import ( + "testing" + "time" +) + +func TestFormatDate(t *testing.T) { + // A single fixed instant rendered in UTC so the expected strings exercise + // each locale's numeric order and separators. + instant := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + tests := []struct { + locale string + want string + }{ + {locale: "en", want: "9/5/2026"}, + {locale: "de", want: "05.09.2026"}, + {locale: "es", want: "5/9/2026"}, + {locale: "ar", want: "5/9/2026"}, + {locale: "pt-BR", want: "5/9/2026"}, + {locale: "ru", want: "05.09.2026"}, + {locale: "zh-CN", want: "2026/9/5"}, + {locale: "fr", want: "05/09/2026"}, + {locale: "ko", want: "2026. 9. 5."}, + {locale: "it", want: "05/09/2026"}, + {locale: "pl", want: "05.09.2026"}, + {locale: "unknown", want: "9/5/2026"}, + {locale: "", want: "9/5/2026"}, + } + for _, tc := range tests { + t.Run(tc.locale, func(t *testing.T) { + if got := FormatDate(tc.locale, instant, time.UTC); got != tc.want { + t.Fatalf("FormatDate(%q) = %q, want %q", tc.locale, got, tc.want) + } + }) + } +} + +func TestFormatTime(t *testing.T) { + instant := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + tests := []struct { + locale string + want string + }{ + {locale: "en", want: "2:30 PM"}, + {locale: "de", want: "14:30"}, + {locale: "zh-CN", want: "14:30"}, + {locale: "pl", want: "14:30"}, + } + for _, tc := range tests { + t.Run(tc.locale, func(t *testing.T) { + if got := FormatTime(tc.locale, instant, time.UTC); got != tc.want { + t.Fatalf("FormatTime(%q) = %q, want %q", tc.locale, got, tc.want) + } + }) + } +} + +func TestFormatDateTimeConvertsTimezone(t *testing.T) { + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("LoadLocation(America/New_York): %v", err) + } + // 2026-09-05 23:30 UTC is 19:30 in New York (EDT, UTC-4). + instant := time.Date(2026, 9, 5, 23, 30, 0, 0, time.UTC) + + if got, want := FormatDateTime("de", instant, newYork), "05.09.2026, 19:30"; got != want { + t.Fatalf("FormatDateTime(de) = %q, want %q", got, want) + } + if got, want := FormatDateTime("en", instant, newYork), "9/5/2026, 7:30 PM"; got != want { + t.Fatalf("FormatDateTime(en) = %q, want %q", got, want) + } +} + +func TestFormatDateTimeZLabelsZone(t *testing.T) { + instant := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + if got, want := FormatDateTimeZ("en", instant, time.UTC), "9/5/2026, 2:30 PM UTC"; got != want { + t.Fatalf("FormatDateTimeZ(en, UTC) = %q, want %q", got, want) + } +} + +func TestFormatNilLocationUsesUTC(t *testing.T) { + instant := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + if got, want := FormatDateTime("en", instant, nil), "9/5/2026, 2:30 PM"; got != want { + t.Fatalf("FormatDateTime(en, nil) = %q, want %q", got, want) + } +} + +func TestLocation(t *testing.T) { + if got := Location(""); got != time.UTC { + t.Fatalf("Location(\"\") = %v, want UTC", got) + } + if got := Location("Not/AZone"); got != time.UTC { + t.Fatalf("Location(Not/AZone) = %v, want UTC", got) + } + if got := Location("UTC"); got != time.UTC { + t.Fatalf("Location(UTC) = %v, want UTC", got) + } + if got := Location("Europe/Berlin"); got.String() != "Europe/Berlin" { + t.Fatalf("Location(Europe/Berlin) = %v, want Europe/Berlin", got) + } +} + +func TestZoneName(t *testing.T) { + if got := ZoneName(nil); got != "UTC" { + t.Fatalf("ZoneName(nil) = %q, want UTC", got) + } + if got := ZoneName(time.UTC); got != "UTC" { + t.Fatalf("ZoneName(UTC) = %q, want UTC", got) + } +} + +// TestGeneratedDateFormatsAreComplete guards the generated table: every shipped +// locale must have all three layouts. A stale generated file fails here. +func TestGeneratedDateFormatsAreComplete(t *testing.T) { + for _, locale := range Supported() { + format, ok := dateFormats[locale] + if !ok { + t.Fatalf("locale %q is missing from the generated date format table", locale) + } + if format.Date == "" || format.DateTime == "" || format.Time == "" { + t.Errorf("locale %q has an incomplete date format: %+v", locale, format) + } + } +} diff --git a/internal/i18n/locale_test.go b/internal/i18n/locale_test.go new file mode 100644 index 0000000..49b66da --- /dev/null +++ b/internal/i18n/locale_test.go @@ -0,0 +1,87 @@ +//go:build test + +package i18n + +import ( + "slices" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + tag string + want string + }{ + {name: "empty defaults to english", tag: "", want: "en"}, + {name: "exact match", tag: "en", want: "en"}, + {name: "case insensitive", tag: "EN", want: "en"}, + {name: "region specific exact", tag: "pt-BR", want: "pt-BR"}, + {name: "underscore separator", tag: "pt_BR", want: "pt-BR"}, + {name: "region falls back to base language", tag: "de-AT", want: "de"}, + {name: "base language matches region locale", tag: "zh", want: "zh-CN"}, + {name: "script tag matches region locale", tag: "zh-Hans", want: "zh-CN"}, + {name: "korean region", tag: "ko-KR", want: "ko"}, + {name: "french region", tag: "fr-CA", want: "fr"}, + {name: "italian exact", tag: "it", want: "it"}, + {name: "polish exact", tag: "pl", want: "pl"}, + {name: "polish region", tag: "pl-PL", want: "pl"}, + {name: "unknown locale defaults to english", tag: "xx", want: "en"}, + {name: "whitespace trimmed", tag: " de ", want: "de"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Normalize(tc.tag); got != tc.want { + t.Fatalf("Normalize(%q) = %q, want %q", tc.tag, got, tc.want) + } + }) + } +} + +func TestSupported(t *testing.T) { + locales := Supported() + // Pinned to the shipped set; update alongside a new locale. The backend + // derives this from the generated date-format table, which mirrors the + // frontend's SUPPORTED_LOCALES. + want := []string{"ar", "de", "en", "es", "fa", "fr", "it", "ko", "pl", "pt-BR", "ru", "zh-CN"} + if !slices.Equal(locales, want) { + t.Fatalf("Supported() = %v, want %v", locales, want) + } + seen := make(map[string]bool, len(locales)) + for _, locale := range locales { + if locale == "" { + t.Fatal("Supported() returned an empty locale") + } + if seen[locale] { + t.Fatalf("Supported() returned duplicate locale %q", locale) + } + seen[locale] = true + } + if !seen[Default] { + t.Fatalf("Supported() does not include the default locale %q", Default) + } + + // Mutating the returned slice must not corrupt package state. + locales[0] = "mutated" + if Supported()[0] == "mutated" { + t.Fatal("Supported() returns the backing slice; callers can mutate package state") + } +} + +func TestIsRTL(t *testing.T) { + tests := []struct { + locale string + want bool + }{ + {locale: "ar", want: true}, + {locale: "ar-EG", want: true}, + {locale: "en", want: false}, + {locale: "de", want: false}, + {locale: "he", want: false}, // unsupported, normalizes to en + } + for _, tc := range tests { + if got := IsRTL(tc.locale); got != tc.want { + t.Errorf("IsRTL(%q) = %v, want %v", tc.locale, got, tc.want) + } + } +} diff --git a/internal/i18n/negotiate_test.go b/internal/i18n/negotiate_test.go new file mode 100644 index 0000000..546cd05 --- /dev/null +++ b/internal/i18n/negotiate_test.go @@ -0,0 +1,51 @@ +//go:build test + +package i18n + +import "testing" + +func TestNegotiateAcceptLanguage(t *testing.T) { + tests := []struct { + name string + header string + want string + ok bool + }{ + {name: "exact region wins", header: "pt-BR,pt;q=0.9", want: "pt-BR", ok: true}, + {name: "base language match", header: "de-DE,de;q=0.9,en;q=0.8", want: "de", ok: true}, + {name: "quality ordering respected", header: "fr;q=0.5,de;q=0.9", want: "de", ok: true}, + {name: "script tag maps to region locale", header: "zh-Hans-CN", want: "zh-CN", ok: true}, + {name: "korean region", header: "ko-KR,ko;q=0.9", want: "ko", ok: true}, + {name: "english region", header: "en-US,en;q=0.9", want: "en", ok: true}, + {name: "unsupported language", header: "xx-YY", want: "", ok: false}, + {name: "empty header", header: "", want: "", ok: false}, + {name: "wildcard only", header: "*", want: "", ok: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := NegotiateAcceptLanguage(tc.header) + if ok != tc.ok || got != tc.want { + t.Fatalf("NegotiateAcceptLanguage(%q) = (%q, %v), want (%q, %v)", tc.header, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestIsSupported(t *testing.T) { + tests := []struct { + tag string + want bool + }{ + {tag: "en", want: true}, + {tag: "de-AT", want: true}, + {tag: "pt_BR", want: true}, + {tag: "zh", want: true}, + {tag: "xx", want: false}, + {tag: "", want: false}, + } + for _, tc := range tests { + if got := IsSupported(tc.tag); got != tc.want { + t.Errorf("IsSupported(%q) = %v, want %v", tc.tag, got, tc.want) + } + } +} diff --git a/internal/i18n/translate_test.go b/internal/i18n/translate_test.go new file mode 100644 index 0000000..e40453e --- /dev/null +++ b/internal/i18n/translate_test.go @@ -0,0 +1,103 @@ +//go:build test + +package i18n + +import ( + "strings" + "testing" + "time" +) + +func TestTranslateInterpolatesPlaceholders(t *testing.T) { + got := Translate("en", "email.magicLink.body", map[string]any{"expires": "30 minutes"}) + want := "Click the button below to sign in. The link is valid for 30 minutes." + if got != want { + t.Fatalf("Translate() = %q, want %q", got, want) + } +} + +func TestTranslateUsesRecipientLocale(t *testing.T) { + got := Translate("de", "email.magicLink.heading", nil) + if want := "Bei Ihrem Portal anmelden"; got != want { + t.Fatalf("Translate(de) = %q, want %q", got, want) + } + if got == Translate("en", "email.magicLink.heading", nil) { + t.Fatal("German translation is identical to English") + } + + polish := Translate("pl", "email.magicLink.heading", nil) + if want := "Zaloguj się do swojego portalu"; polish != want { + t.Fatalf("Translate(pl) = %q, want %q", polish, want) + } + if polish == Translate("en", "email.magicLink.heading", nil) { + t.Fatal("Polish translation is identical to English") + } +} + +func TestTranslateUnknownLocaleFallsBackToEnglish(t *testing.T) { + got := Translate("xx", "email.magicLink.heading", nil) + if want := "Sign in to your portal"; got != want { + t.Fatalf("Translate(xx) = %q, want English %q", got, want) + } +} + +func TestTranslateRegionTagFallsBackToBaseLanguage(t *testing.T) { + // de-AT is not shipped; it should resolve to de rather than English. + got := Translate("de-AT", "email.magicLink.heading", nil) + if want := "Bei Ihrem Portal anmelden"; got != want { + t.Fatalf("Translate(de-AT) = %q, want German %q", got, want) + } +} + +func TestTranslateMissingKeyReturnsKey(t *testing.T) { + if got := Translate("en", "email.does.not.exist", nil); got != "email.does.not.exist" { + t.Fatalf("Translate(missing) = %q, want the key itself", got) + } +} + +func TestTranslateLeavesUnknownPlaceholders(t *testing.T) { + got := Translate("en", "email.magicLink.body", map[string]any{"other": "x"}) + if want := "Click the button below to sign in. The link is valid for {expires}."; got != want { + t.Fatalf("Translate(unknown placeholder) = %q, want %q", got, want) + } +} + +func TestTranslateOK(t *testing.T) { + if _, ok := TranslateOK("de", "email.magicLink.heading", nil); !ok { + t.Fatal("known key reported no translation") + } + if _, ok := TranslateOK("de", "notification.doesNotExist", nil); ok { + t.Fatal("unknown key reported a translation") + } + got, ok := TranslateOK("en", "email.magicLink.body", map[string]any{"expires": "1 hour"}) + if !ok || !strings.Contains(got, "1 hour") { + t.Fatalf("TranslateOK interpolation = (%q, %v)", got, ok) + } +} + +func TestHumanizeDuration(t *testing.T) { + tests := []struct { + locale string + d time.Duration + want string + }{ + {locale: "en", d: 30 * time.Minute, want: "30 minutes"}, + {locale: "en", d: time.Minute, want: "1 minute"}, + {locale: "en", d: 24 * time.Hour, want: "1 day"}, + {locale: "en", d: 7 * 24 * time.Hour, want: "7 days"}, + {locale: "de", d: 30 * time.Minute, want: "30 Minuten"}, + {locale: "de", d: 24 * time.Hour, want: "1 Tag"}, + {locale: "fr", d: 7 * 24 * time.Hour, want: "7 jours"}, + {locale: "zh-CN", d: 30 * time.Minute, want: "30 分钟"}, + {locale: "ru", d: 24 * time.Hour, want: "1 Đ´ĐĩĐŊҌ"}, + {locale: "pl", d: 30 * time.Minute, want: "30 minut"}, + {locale: "pl", d: time.Minute, want: "1 minuta"}, + {locale: "pl", d: 24 * time.Hour, want: "1 dzień"}, + {locale: "pl", d: 7 * 24 * time.Hour, want: "7 dni"}, + } + for _, tc := range tests { + if got := HumanizeDuration(tc.locale, tc.d); got != tc.want { + t.Errorf("HumanizeDuration(%q, %s) = %q, want %q", tc.locale, tc.d, got, tc.want) + } + } +} diff --git a/internal/integrations/docmost/client_test.go b/internal/integrations/docmost/client_test.go new file mode 100644 index 0000000..072461d --- /dev/null +++ b/internal/integrations/docmost/client_test.go @@ -0,0 +1,193 @@ +//go:build test + +package docmost + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "windshift/internal/utils" +) + +func allowLocal(t *testing.T) { + t.Helper() + previous := utils.AllowLocalConnections() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(previous) }) +} + +func TestNormalizeBaseURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "https", input: "https://wiki.example.com", want: "https://wiki.example.com"}, + {name: "http private host", input: "http://localhost:3200", want: "http://localhost:3200"}, + {name: "trailing slash trimmed", input: "https://wiki.example.com/", want: "https://wiki.example.com"}, + {name: "whitespace trimmed", input: " https://wiki.example.com ", want: "https://wiki.example.com"}, + {name: "empty", input: "", wantErr: true}, + {name: "wrong scheme", input: "ftp://wiki.example.com", wantErr: true}, + {name: "no host", input: "https://", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeBaseURL(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + if !errors.Is(err, ErrInvalidURL) { + t.Fatalf("error = %v, want ErrInvalidURL", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestSearchParsesEnvelope(t *testing.T) { + allowLocal(t) + var gotAuth, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/search" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"p1","slugId":"AbC","title":"Runbook","highlight":"the deploy steps","rank":0.9,"space":{"name":"Eng","slug":"eng"}}]}}`)) + })) + t.Cleanup(srv.Close) + + results, err := NewClient().Search(context.Background(), srv.URL, "dcm_token", "deploy", 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if gotAuth != "Bearer dcm_token" { + t.Fatalf("Authorization = %q", gotAuth) + } + if !strings.Contains(gotBody, `"query":"deploy"`) { + t.Fatalf("request body = %q", gotBody) + } + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + want := SearchResult{ID: "p1", SlugID: "AbC", Title: "Runbook", Highlight: "the deploy steps", Rank: 0.9, SpaceName: "Eng", SpaceSlug: "eng"} + if results[0] != want { + t.Fatalf("result = %+v, want %+v", results[0], want) + } + if url := PageURL(srv.URL, "eng", "AbC"); url != srv.URL+"/s/eng/p/AbC" { + t.Fatalf("page URL = %q", url) + } +} + +func TestSearchMapsUnauthorized(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized"}`)) + })) + t.Cleanup(srv.Close) + + if _, err := NewClient().Search(context.Background(), srv.URL, "bad", "q", 10); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("search error = %v, want ErrUnauthorized", err) + } +} + +func TestSearchCapsResponse(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"items":[]}}`)) + _, _ = w.Write([]byte(strings.Repeat(" ", maxResponseBytes+1))) + })) + t.Cleanup(srv.Close) + + if _, err := NewClient().Search(context.Background(), srv.URL, "token", "q", 10); !errors.Is(err, ErrUnavailable) { + t.Fatalf("search error = %v, want ErrUnavailable", err) + } +} + +func TestRegisterClient(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/oauth/register" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id":"client-abc"}`)) + })) + t.Cleanup(srv.Close) + + clientID, err := NewClient().RegisterClient(context.Background(), srv.URL, "Windshift", "http://windshift.test/cb") + if err != nil { + t.Fatalf("register: %v", err) + } + if clientID != "client-abc" { + t.Fatalf("client id = %q", clientID) + } +} + +func TestRegisterClientOAuthDisabled(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + if _, err := NewClient().RegisterClient(context.Background(), srv.URL, "Windshift", "http://windshift.test/cb"); !errors.Is(err, ErrOAuthDisabled) { + t.Fatalf("register error = %v, want ErrOAuthDisabled", err) + } +} + +func TestExchangeCode(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/oauth/token" { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + if r.PostFormValue("grant_type") != "authorization_code" || r.PostFormValue("code_verifier") != "verifier" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = w.Write([]byte(`{"access_token":"acc","refresh_token":"ref","token_type":"Bearer","scope":"read write","expires_in":3600}`)) + })) + t.Cleanup(srv.Close) + + token, err := NewClient().ExchangeCode(context.Background(), srv.URL, "client-abc", "http://windshift.test/cb", "code", "verifier") + if err != nil { + t.Fatalf("exchange: %v", err) + } + if token.AccessToken != "acc" || token.RefreshToken != "ref" || token.ExpiresAt.IsZero() { + t.Fatalf("token = %+v", token) + } +} + +func TestExchangeCodeRejected(t *testing.T) { + allowLocal(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) + })) + t.Cleanup(srv.Close) + + if _, err := NewClient().ExchangeCode(context.Background(), srv.URL, "client-abc", "http://windshift.test/cb", "bad", "verifier"); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("exchange error = %v, want ErrUnauthorized", err) + } +} diff --git a/internal/jira/bulk_fetch_test.go b/internal/jira/bulk_fetch_test.go index 62f7c39..c10d53d 100644 --- a/internal/jira/bulk_fetch_test.go +++ b/internal/jira/bulk_fetch_test.go @@ -8,6 +8,36 @@ import ( // TestBulkFetchResponseDecodesIssueErrors pins the WI-1701 wire contract: // Jira Cloud answers POST /rest/api/3/issue/bulkfetch with HTTP 200, resolved // issues under "issues", and per-issue failures under "issueErrors". +// TestBulkFetchResponseDecodesPublishedIssueError is the WI-2065 contract: the +// published BulkIssueResults/IssueError shape is {id, errorMessage}, where id is +// the numeric issue id rather than the issue key. +func TestBulkFetchResponseDecodesPublishedIssueError(t *testing.T) { + t.Parallel() + + raw := []byte(`{ + "issues": [ + {"id": "10001", "key": "APP-1", "fields": {"summary": "Visible issue"}} + ], + "issueErrors": [ + {"id": "10002", "errorMessage": "Issue does not exist or you do not have permission to see it."} + ] + }`) + + var result BulkFetchResponse + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("decode bulk fetch response: %v", err) + } + if len(result.IssueErrors) != 1 { + t.Fatalf("issueErrors = %#v, want 1 entry", result.IssueErrors) + } + if result.IssueErrors[0].ID != "10002" { + t.Fatalf("issue error id = %q, want 10002", result.IssueErrors[0].ID) + } + if got, want := result.IssueErrors[0].Message(), "Issue does not exist or you do not have permission to see it."; got != want { + t.Fatalf("issue error message = %q, want %q", got, want) + } +} + func TestBulkFetchResponseDecodesIssueErrors(t *testing.T) { t.Parallel() diff --git a/internal/jira/client_auth_test.go b/internal/jira/client_auth_test.go index 0b0b85f..4cfdb74 100644 --- a/internal/jira/client_auth_test.go +++ b/internal/jira/client_auth_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -69,6 +70,172 @@ func TestJiraAuthHeaderMatchesDeploymentContract(t *testing.T) { } } +func newCloudTestClient(server *httptest.Server) *cloudClient { + return &cloudClient{ + baseURL: server.URL + "/rest/api/3", + authHeader: "Basic test-token", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } +} + +// TestCloudRoutingProbeKeepsGatewayWhenMyselfLacksScope is the WI-2060 +// regression: a scoped token authorized for issue reads but not read:me gets +// 403 from gateway /myself and must still route through the gateway. Only an +// outright 401 (legacy token or invalid credentials) selects the site URL, and +// a transient probe failure must not downgrade routing. +func TestCloudRoutingProbeKeepsGatewayWhenMyselfLacksScope(t *testing.T) { + t.Parallel() + + const cloudID = "cloud-scope" + tests := []struct { + name string + myselfStatus int + wantViaGateway bool + }{ + {name: "missing read:me scope keeps gateway", myselfStatus: http.StatusForbidden, wantViaGateway: true}, + {name: "rejected credential uses site", myselfStatus: http.StatusUnauthorized, wantViaGateway: false}, + {name: "transient probe keeps gateway", myselfStatus: http.StatusInternalServerError, wantViaGateway: true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var sitePaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/_edge/tenant_info": + _, _ = fmt.Fprintf(w, `{"cloudId":%q}`, cloudID) + case "/gateway/" + cloudID + "/rest/api/3/myself": + w.WriteHeader(test.myselfStatus) + case "/gateway/" + cloudID + "/rest/servicedeskapi/assets/workspace": + w.WriteHeader(http.StatusNotFound) + case "/rest/servicedeskapi/assets/workspace": + sitePaths = append(sitePaths, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + routing := cloudRoutingProbeWithGateway(server.URL, "Basic test-token", server.Client(), server.URL+"/gateway/") + if routing.viaGateway != test.wantViaGateway { + t.Fatalf("viaGateway = %t, want %t", routing.viaGateway, test.wantViaGateway) + } + if test.wantViaGateway { + wantPlatform := server.URL + "/gateway/" + cloudID + "/rest/api/3" + if routing.platformBase != wantPlatform { + t.Fatalf("platformBase = %q, want %q", routing.platformBase, wantPlatform) + } + if len(sitePaths) != 0 { + t.Fatalf("gateway routing made site requests: %v", sitePaths) + } + } + }) + } +} + +// TestCloudTestConnectionRequiresAuthenticatedEvidence is the WI-2064/WI-1153 +// regression: anonymous /serverInfo must not validate credentials. Only a 401 +// from /myself (authenticated endpoint) rejects the token; a 403 means the +// token authenticated but lacks read:me and must still connect. +func TestCloudTestConnectionRequiresAuthenticatedEvidence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + myselfStatus int + myselfBody string + serverInfoBody string + wantErr error + wantDisplayName string + wantURL string + }{ + { + name: "anonymous serverInfo cannot validate a rejected token", + myselfStatus: http.StatusUnauthorized, + myselfBody: `{"errorMessages":["Client must be authenticated"]}`, + serverInfoBody: `{"baseUrl":"https://example.atlassian.net","serverTitle":"Example"}`, + wantErr: ErrInvalidCredentials, + }, + { + name: "scoped token without read:me still connects", + myselfStatus: http.StatusForbidden, + myselfBody: `{"errorMessages":["scope does not match"]}`, + serverInfoBody: `{"baseUrl":"https://example.atlassian.net","serverTitle":"Example"}`, + wantDisplayName: "Example", + wantURL: "https://example.atlassian.net", + }, + { + name: "authenticated identity enriches the display name", + myselfStatus: http.StatusOK, + myselfBody: `{"displayName":"Jane Operator"}`, + serverInfoBody: `{"baseUrl":"https://example.atlassian.net","serverTitle":"Example"}`, + wantDisplayName: "Jane Operator", + wantURL: "https://example.atlassian.net", + }, + { + name: "fallback URL strips the REST prefix", + myselfStatus: http.StatusOK, + myselfBody: `{"displayName":"Jane Operator"}`, + serverInfoBody: `{"serverTitle":"Example"}`, + wantDisplayName: "Jane Operator", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/rest/api/3/myself": + w.WriteHeader(test.myselfStatus) + _, _ = w.Write([]byte(test.myselfBody)) + case "/rest/api/3/serverInfo": + _, _ = w.Write([]byte(test.serverInfoBody)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := newCloudTestClient(server) + info, err := client.TestConnection(context.Background()) + if test.wantErr != nil { + if !errors.Is(err, test.wantErr) { + t.Fatalf("TestConnection() error = %v, want %v", err, test.wantErr) + } + if info != nil { + t.Fatalf("TestConnection() info = %#v, want nil", info) + } + if len(paths) != 1 || paths[0] != "/rest/api/3/myself" { + t.Fatalf("request paths = %v, want only /rest/api/3/myself", paths) + } + return + } + if err != nil { + t.Fatalf("TestConnection() error = %v", err) + } + if info.DisplayName != test.wantDisplayName { + t.Fatalf("DisplayName = %q, want %q", info.DisplayName, test.wantDisplayName) + } + if test.wantURL != "" && info.URL != test.wantURL { + t.Fatalf("URL = %q, want %q", info.URL, test.wantURL) + } + if test.wantURL == "" && info.URL != server.URL { + t.Fatalf("URL = %q, want stripped fallback %q", info.URL, server.URL) + } + }) + } +} + func TestDataCenterClientUsesPATBearerAuthenticationWithoutUsername(t *testing.T) { t.Parallel() diff --git a/internal/jira/client_datacenter_assets_test.go b/internal/jira/client_datacenter_assets_test.go new file mode 100644 index 0000000..fd1d4a8 --- /dev/null +++ b/internal/jira/client_datacenter_assets_test.go @@ -0,0 +1,24 @@ +package jira + +import ( + "context" + "errors" + "testing" +) + +// TestDataCenterListObjectSchemasReportsUnavailable is the WI-2078 regression: +// Data Center Assets object reads are unimplemented, so the whole capability +// must be reported unavailable instead of advertising discoverable schemas that +// become empty native sets. +func TestDataCenterListObjectSchemasReportsUnavailable(t *testing.T) { + t.Parallel() + + client := &dataCenterClient{} + schemas, err := client.ListObjectSchemas(context.Background()) + if !errors.Is(err, ErrAssetsNotAvailable) { + t.Fatalf("ListObjectSchemas() error = %v, want ErrAssetsNotAvailable", err) + } + if len(schemas) != 0 { + t.Fatalf("schemas = %#v, want none", schemas) + } +} diff --git a/internal/jira/client_datacenter_filters_test.go b/internal/jira/client_datacenter_filters_test.go new file mode 100644 index 0000000..db4aae3 --- /dev/null +++ b/internal/jira/client_datacenter_filters_test.go @@ -0,0 +1,56 @@ +package jira + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/time/rate" +) + +// TestDataCenterListFiltersUsesFavoriteEndpoint is the WI-2076 regression: +// Data Center has no Cloud-style project filter search, so discovery must use +// the supported favorite-filter endpoint and report the enumeration boundary. +func TestDataCenterListFiltersUsesFavoriteEndpoint(t *testing.T) { + t.Parallel() + + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/rest/api/2/filter/favourite": + _ = json.NewEncoder(w).Encode([]JiraFilter{{ + ID: "10001", Name: "My open bugs", JQL: "project = HELP AND status = Open", + }}) + case "/rest/api/2/filter/search": + http.Error(w, "unsupported", http.StatusNotFound) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := &dataCenterClient{ + baseURL: server.URL + "/rest/api/2", + authHeader: "Bearer pat", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + result, err := client.ListFilters(context.Background(), "HELP") + if err != nil { + t.Fatalf("ListFilters() error = %v", err) + } + if len(result.Values) != 1 || result.Values[0].ID != "10001" { + t.Fatalf("filters = %#v, want the favorite filter", result.Values) + } + if !result.Partial || result.PartialReason == "" { + t.Fatalf("result = %#v, want partial discovery with a reason", result) + } + if len(paths) != 1 || paths[0] != "/rest/api/2/filter/favourite" { + t.Fatalf("request paths = %v, want only /rest/api/2/filter/favourite", paths) + } +} diff --git a/internal/jira/client_retry_test.go b/internal/jira/client_retry_test.go index 91b270f..ffe14bd 100644 --- a/internal/jira/client_retry_test.go +++ b/internal/jira/client_retry_test.go @@ -3,6 +3,8 @@ package jira import ( "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "testing" @@ -108,8 +110,16 @@ func TestJiraClientsRetryRateLimitsAndServiceUnavailable(t *testing.T) { if requests != 3 { t.Fatalf("requests = %d, want 3", requests) } - if len(delays) != 2 || delays[0] != 2*time.Second || delays[1] != 500*time.Millisecond { - t.Fatalf("retry delays = %#v, want [2s 500ms]", delays) + if len(delays) != 2 { + t.Fatalf("retry delays = %#v, want 2 delays", delays) + } + if delays[0] != 2*time.Second { + t.Fatalf("Retry-After delay = %v, want the exact 2s", delays[0]) + } + // Without Retry-After the backoff is jittered, so only its bounds are + // contractual: equal jitter over the 500ms base for attempt 1. + if delays[1] < 250*time.Millisecond || delays[1] > 500*time.Millisecond { + t.Fatalf("backoff delay = %v, want within [250ms, 500ms]", delays[1]) } }) } @@ -156,3 +166,219 @@ func TestCloudClientRetriesReadOnlyPostWithOriginalBody(t *testing.T) { t.Fatalf("status = %d requests = %d, want 200 and 2", response.StatusCode, requests) } } + +// TestJiraDownloadAttachmentRetriesRateLimit is the WI-2010 contract: the +// attachment download path shares the JSON path's retry behavior instead of +// dropping the file on the first 429. +func TestJiraDownloadAttachmentRetriesRateLimit(t *testing.T) { + t.Parallel() + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if requests == 1 { + w.Header().Set("Retry-After", "0") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("attachment body")) + })) + t.Cleanup(server.Close) + + reader, contentType, err := jiraDownloadAttachment( + context.Background(), + server.Client(), + rate.NewLimiter(rate.Inf, 1), + &jiraThrottle{}, + "Bearer token", + server.URL+"/attachment", + ) + if err != nil { + t.Fatalf("download error = %v", err) + } + defer func() { _ = reader.Close() }() + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read body: %v", err) + } + if requests != 2 || string(body) != "attachment body" || contentType != "text/plain" { + t.Fatalf("requests=%d body=%q contentType=%q, want a retried download", requests, body, contentType) + } +} + +type failingTransport struct { + err error + calls int +} + +func (f *failingTransport) RoundTrip(*http.Request) (*http.Response, error) { + f.calls++ + return nil, f.err +} + +// TestJiraRetryRequestRetriesTransientStatuses covers the broader retryable set +// so a proxy 500/502/504 does not fail the import immediately. +func TestJiraRetryRequestRetriesTransientStatuses(t *testing.T) { + t.Parallel() + + statuses := []int{ + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } + for _, status := range statuses { + status := status + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if requests == 1 { + http.Error(w, "transient", status) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(server.Close) + + var delays []time.Duration + response, err := jiraRetryRequest( + context.Background(), + server.Client(), + rate.NewLimiter(rate.Inf, 1), + &jiraThrottle{}, + 1, + func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + return nil + }, + func() (*http.Request, error) { + return http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/rest/api/3/field", http.NoBody) + }, + ) + if err != nil { + t.Fatalf("request error = %v", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK || requests != 2 || len(delays) != 1 { + t.Fatalf("status=%d requests=%d delays=%v, want one retry", response.StatusCode, requests, delays) + } + }) + } +} + +// TestJiraRetryRequestRetriesNetworkErrors covers a dropped connection, which +// previously failed the request outright. +func TestJiraRetryRequestRetriesNetworkErrors(t *testing.T) { + t.Parallel() + + transport := &failingTransport{err: errors.New("connection reset by peer")} + client := &http.Client{Transport: transport} + var delays []time.Duration + _, err := jiraRetryRequest( + context.Background(), + client, + rate.NewLimiter(rate.Inf, 1), + &jiraThrottle{}, + 2, + func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + return nil + }, + func() (*http.Request, error) { + return http.NewRequestWithContext(context.Background(), http.MethodGet, "https://jira.example.invalid/rest/api/3/field", http.NoBody) + }, + ) + if err == nil { + t.Fatal("request error = nil, want the network error after retries") + } + if transport.calls != 3 || len(delays) != 2 { + t.Fatalf("calls=%d delays=%v, want 3 attempts and 2 backoffs", transport.calls, delays) + } +} + +// TestJiraThrottleCooldownSemantics pins the shared cooldown contract: it never +// shortens, and wait observes an active cooldown. +func TestJiraThrottleCooldownSemantics(t *testing.T) { + t.Parallel() + + if err := (&jiraThrottle{}).wait(context.Background()); err != nil { + t.Fatalf("idle throttle wait = %v, want nil", err) + } + + throttle := &jiraThrottle{} + before := time.Now() + throttle.pause(2 * time.Second) + throttle.mu.Lock() + until := throttle.until + throttle.mu.Unlock() + if until.Before(before.Add(2*time.Second)) || until.After(time.Now().Add(2*time.Second)) { + t.Fatalf("cooldown until = %v, want ~now+2s", until) + } + + throttle.pause(time.Millisecond) + throttle.mu.Lock() + afterShortPause := throttle.until + throttle.mu.Unlock() + if !afterShortPause.Equal(until) { + t.Fatalf("shorter pause moved cooldown from %v to %v", until, afterShortPause) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := throttle.wait(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("wait during cooldown = %v, want context.Canceled", err) + } +} + +// TestJiraRetryRequestPausesSharedThrottleOnRateLimit proves one worker's 429 +// pauses the whole client instead of only itself. +func TestJiraRetryRequestPausesSharedThrottleOnRateLimit(t *testing.T) { + t.Parallel() + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.Header().Set("Retry-After", "30") + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + throttle := &jiraThrottle{} + var delays []time.Duration + _, err := jiraRetryRequest( + ctx, + server.Client(), + rate.NewLimiter(rate.Inf, 1), + throttle, + 1, + func(_ context.Context, delay time.Duration) error { + delays = append(delays, delay) + // Cancel so the next attempt's cooldown wait returns immediately + // instead of blocking for the real 30s window. + cancel() + return nil + }, + func() (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/rest/api/3/field", http.NoBody) + }, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("request error = %v, want context.Canceled from the cooldown wait", err) + } + if len(delays) != 1 || delays[0] != 30*time.Second { + t.Fatalf("delays = %v, want [30s]", delays) + } + throttle.mu.Lock() + until := throttle.until + throttle.mu.Unlock() + if !until.After(time.Now()) { + t.Fatalf("throttle cooldown until = %v, want a future pause from the Retry-After window", until) + } +} diff --git a/internal/jira/client_service_management_test.go b/internal/jira/client_service_management_test.go index 273b840..5094763 100644 --- a/internal/jira/client_service_management_test.go +++ b/internal/jira/client_service_management_test.go @@ -84,3 +84,54 @@ func TestCloudClientReadsServiceManagementEntitiesWithGet(t *testing.T) { } } } + +// TestDataCenterClientSendsExperimentalOptInForOrganizationReads is the WI-2074 +// regression: JSM 5.x requires X-ExperimentalApi on the organization-list and +// organization-member reads, which are read-only. +func TestDataCenterClientSendsExperimentalOptInForOrganizationReads(t *testing.T) { + t.Parallel() + + var orgOptIns, memberOptIns []string + 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/servicedeskapi/servicedesk/1/organization": + orgOptIns = append(orgOptIns, r.Header.Get("X-ExperimentalApi")) + _ = json.NewEncoder(w).Encode(JiraServiceDeskPage[JiraServiceDeskOrganization]{ + IsLastPage: true, + Values: []JiraServiceDeskOrganization{{ID: "4", Name: "Example Customer"}}, + }) + case "/rest/servicedeskapi/organization/4/user": + memberOptIns = append(memberOptIns, r.Header.Get("X-ExperimentalApi")) + _ = json.NewEncoder(w).Encode(JiraServiceDeskPage[JiraUser]{ + IsLastPage: true, + Values: []JiraUser{{Name: "customer"}}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := &dataCenterClient{ + serviceDeskURL: server.URL + "/rest/servicedeskapi", + authHeader: "Bearer pat", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + if _, err := client.ListServiceDeskOrganizations(context.Background(), "1"); err != nil { + t.Fatalf("ListServiceDeskOrganizations() error = %v", err) + } + if _, err := client.ListServiceDeskOrganizationUsers(context.Background(), "4"); err != nil { + t.Fatalf("ListServiceDeskOrganizationUsers() error = %v", err) + } + for _, header := range append(orgOptIns, memberOptIns...) { + if header != "true" { + t.Fatalf("X-ExperimentalApi = %q, want true", header) + } + } + if len(orgOptIns) == 0 || len(memberOptIns) == 0 { + t.Fatalf("opt-in headers not observed: org=%v member=%v", orgOptIns, memberOptIns) + } +} diff --git a/internal/jira/cloud_assets_pagination_test.go b/internal/jira/cloud_assets_pagination_test.go new file mode 100644 index 0000000..acbfe4e --- /dev/null +++ b/internal/jira/cloud_assets_pagination_test.go @@ -0,0 +1,57 @@ +package jira + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/time/rate" +) + +// TestCloudSearchObjectsHonorsExplicitStartAt is the WI-2070 regression: the +// Assets search must be able to continue from an exact offset rather than only +// from a requested page size. +func TestCloudSearchObjectsHonorsExplicitStartAt(t *testing.T) { + t.Parallel() + + var startAts []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/jsm/assets/workspace/ws/v1/object/aql" { + t.Fatalf("path = %s, want the Assets AQL path", r.URL.Path) + } + startAts = append(startAts, r.URL.Query().Get("startAt")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "values": []any{}, + "startAt": 25, + "maxResults": 100, + "total": 0, + "isLast": true, + }) + })) + t.Cleanup(server.Close) + + client := &cloudClient{ + assetsURL: server.URL + "/jsm/assets/workspace/ws/v1", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + result, err := client.SearchObjects(context.Background(), ObjectSearchOptions{ + ObjectSchemaID: "7", StartAt: 25, Page: 1, PageSize: 100, IncludeAttributes: true, + }) + if err != nil { + t.Fatalf("SearchObjects() error = %v", err) + } + if len(startAts) != 1 || startAts[0] != "25" { + t.Fatalf("requested startAt = %v, want [25]", startAts) + } + if result.StartIndex != 25 || !result.IsLast { + t.Fatalf("result = %#v, want startIndex 25 and isLast", result) + } +} diff --git a/internal/jira/cloud_attachment_routing_test.go b/internal/jira/cloud_attachment_routing_test.go new file mode 100644 index 0000000..5161f1e --- /dev/null +++ b/internal/jira/cloud_attachment_routing_test.go @@ -0,0 +1,89 @@ +package jira + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/time/rate" +) + +// TestCloudDownloadAttachmentRoutesThroughResolvedBase is the WI-2061 +// regression: a Cloud attachment download must use the attachment-content +// endpoint under the client's resolved base (the gateway for scoped tokens) +// instead of the tenant-hosted content URL Jira returns. +func TestCloudDownloadAttachmentRoutesThroughResolvedBase(t *testing.T) { + t.Parallel() + + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if got := r.Header.Get("Authorization"); got != "Basic test-token" { + t.Fatalf("Authorization = %q, want Basic test-token", got) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("png-bytes")) + })) + t.Cleanup(server.Close) + + client := &cloudClient{ + baseURL: server.URL + "/rest/api/3", + authHeader: "Basic test-token", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + // The tenant-hosted URL points at a host this test never starts; if the + // client used it the download would fail instead of reaching the server. + tenantURL := "https://tenant.example.invalid/rest/api/3/attachment/content/10001" + body, contentType, err := client.DownloadAttachment(context.Background(), "10001", tenantURL) + if err != nil { + t.Fatalf("DownloadAttachment() error = %v", err) + } + defer func() { _ = body.Close() }() + + if contentType != "image/png" { + t.Fatalf("content type = %q, want image/png", contentType) + } + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(data) != "png-bytes" { + t.Fatalf("body = %q, want png-bytes", data) + } + if len(paths) != 1 || paths[0] != "/rest/api/3/attachment/content/10001" { + t.Fatalf("request paths = %v, want [/rest/api/3/attachment/content/10001]", paths) + } +} + +// TestCloudDownloadAttachmentFallsBackToContentURL covers attachments without +// an id: the client must still download from the supplied URL. +func TestCloudDownloadAttachmentFallsBackToContentURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("fallback")) + })) + t.Cleanup(server.Close) + + client := &cloudClient{ + baseURL: server.URL + "/rest/api/3", + authHeader: "Basic test-token", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + body, _, err := client.DownloadAttachment(context.Background(), "", server.URL+"/legacy/content") + if err != nil { + t.Fatalf("DownloadAttachment() error = %v", err) + } + defer func() { _ = body.Close() }() + data, _ := io.ReadAll(body) + if string(data) != "fallback" { + t.Fatalf("body = %q, want fallback", data) + } +} diff --git a/internal/jira/cloud_workflow_conditions_test.go b/internal/jira/cloud_workflow_conditions_test.go new file mode 100644 index 0000000..df31a3a --- /dev/null +++ b/internal/jira/cloud_workflow_conditions_test.go @@ -0,0 +1,105 @@ +package jira + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/time/rate" +) + +// TestCloudWorkflowConfigurationDecodesObjectConditions is the WI-2079 +// regression: Jira Cloud returns transition `conditions` as an object for some +// workflows, and decoding it must not fail the whole configured workflow read. +func TestCloudWorkflowConfigurationDecodesObjectConditions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conditions any + wantConditionCount int + }{ + { + name: "object shape", + conditions: map[string]any{ + "operation": "ALL", + "conditionGroups": []any{}, + "conditions": []any{map[string]any{"ruleKey": "system:block-in-progress-approval"}}, + }, + wantConditionCount: 1, + }, + { + name: "legacy array shape", + conditions: []any{map[string]any{"ruleKey": "system:block-in-progress-approval"}}, + wantConditionCount: 1, + }, + { + name: "absent conditions", + conditions: nil, + wantConditionCount: 0, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/rest/api/3/workflows" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + transition := map[string]any{ + "id": "81", "name": "Draft", "type": "DIRECTED", + "toStatusReference": "10025", + "links": []any{map[string]any{"fromStatusReference": "10024"}}, + "validators": []any{}, + "actions": []any{}, + "triggers": []any{}, + } + if test.conditions != nil { + transition["conditions"] = test.conditions + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "statuses": []any{ + map[string]any{"id": "1", "statusReference": "10024"}, + map[string]any{"id": "2", "statusReference": "10025"}, + }, + "workflows": []any{map[string]any{ + "id": "wf-1", "name": "SP workflow", + "statuses": []any{map[string]any{"statusReference": "10024"}, map[string]any{"statusReference": "10025"}}, + "transitions": []any{transition}, + }}, + }) + })) + t.Cleanup(server.Close) + + client := &cloudClient{ + baseURL: server.URL + "/rest/api/3", + authHeader: "Basic test-token", + httpClient: server.Client(), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + config, err := client.GetProjectWorkflowConfiguration(context.Background(), "10002", []string{"10013"}) + if err != nil { + t.Fatalf("GetProjectWorkflowConfiguration() error = %v", err) + } + if config.IssueTypeWorkflowIDs["10013"] != "wf-1" { + t.Fatalf("issue type workflow = %q, want wf-1", config.IssueTypeWorkflowIDs["10013"]) + } + if len(config.Workflows) != 1 || len(config.Workflows[0].Transitions) != 1 { + t.Fatalf("workflows = %#v, want one workflow with one transition", config.Workflows) + } + workflow := config.Workflows[0] + if got := workflow.Transitions[0].ConditionCount; got != test.wantConditionCount { + t.Fatalf("ConditionCount = %d, want %d", got, test.wantConditionCount) + } + if workflow.Transitions[0].FromStatusIDs[0] != "1" { + t.Fatalf("FromStatusIDs = %#v, want the resolved source status", workflow.Transitions[0].FromStatusIDs) + } + }) + } +} diff --git a/internal/jira/jql_translate_test.go b/internal/jira/jql_translate_test.go index 1a45a4e..0181153 100644 --- a/internal/jira/jql_translate_test.go +++ b/internal/jira/jql_translate_test.go @@ -73,3 +73,44 @@ func TestTranslateJQLRequestTypeAndOrganization(t *testing.T) { } } } + +func TestTranslateJQLReportsMembershipCompleteness(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jql string + wantComplete bool + wantUnsupported bool + }{ + {name: "unsupported predicate is incomplete", jql: `project = APP AND assignee = currentUser()`, wantComplete: false, wantUnsupported: true}, + {name: "ordering only keeps membership complete", jql: `project = APP AND status = Open ORDER BY created DESC`, wantComplete: true, wantUnsupported: true}, + {name: "fully supported stays complete", jql: `project = APP AND status = Open`, wantComplete: true, wantUnsupported: false}, + {name: "empty jql stays complete", jql: ``, wantComplete: true, wantUnsupported: false}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + translation := TranslateJQL(test.jql, 11) + if translation.MembershipComplete != test.wantComplete { + t.Fatalf("MembershipComplete = %t, want %t (unsupported=%#v)", translation.MembershipComplete, test.wantComplete, translation.Unsupported) + } + if (len(translation.Unsupported) > 0) != test.wantUnsupported { + t.Fatalf("unsupported = %#v, want any=%t", translation.Unsupported, test.wantUnsupported) + } + }) + } +} + +func TestUnsupportedJQLSelectorMatchesNothing(t *testing.T) { + t.Parallel() + + selector := UnsupportedJQLSelector(7) + if !strings.Contains(selector, `workspace_id = 7`) { + t.Fatalf("selector = %q, want the workspace guard", selector) + } + if !strings.Contains(selector, `1 = 0`) { + t.Fatalf("selector = %q, want a non-matching predicate", selector) + } +} diff --git a/internal/jira/search_expand_wire_test.go b/internal/jira/search_expand_wire_test.go new file mode 100644 index 0000000..a9d693b --- /dev/null +++ b/internal/jira/search_expand_wire_test.go @@ -0,0 +1,63 @@ +package jira + +import ( + "encoding/json" + "testing" +) + +// TestJQLSearchRequestSerializesExpandAsString is the WI-2063 regression: the +// enhanced-search request bean expects expand as a comma-delimited string, not +// an array. Bulk fetch keeps its array shape. +func TestJQLSearchRequestSerializesExpandAsString(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(JQLSearchRequest{ + JQL: `project = APP`, + MaxResults: 100, + Expand: []string{"changelog", "renderedFields"}, + }) + if err != nil { + t.Fatalf("marshal JQLSearchRequest: %v", err) + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode JQLSearchRequest: %v", err) + } + if got, want := body["expand"], "changelog,renderedFields"; got != want { + t.Fatalf("enhanced-search expand = %#v, want %q", got, want) + } + + rawBulk, err := json.Marshal(BulkFetchRequest{ + IssueIdsOrKeys: []string{"APP-1"}, + Expand: []string{"changelog"}, + }) + if err != nil { + t.Fatalf("marshal BulkFetchRequest: %v", err) + } + var bulkBody map[string]any + if err := json.Unmarshal(rawBulk, &bulkBody); err != nil { + t.Fatalf("decode BulkFetchRequest: %v", err) + } + expand, ok := bulkBody["expand"].([]any) + if !ok || len(expand) != 1 || expand[0] != "changelog" { + t.Fatalf("bulk-fetch expand = %#v, want [changelog]", bulkBody["expand"]) + } +} + +// TestJQLSearchRequestOmitsEmptyExpand keeps the request body free of an empty +// expand field so Jira applies its own default. +func TestJQLSearchRequestOmitsEmptyExpand(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(JQLSearchRequest{JQL: `project = APP`}) + if err != nil { + t.Fatalf("marshal JQLSearchRequest: %v", err) + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode JQLSearchRequest: %v", err) + } + if _, present := body["expand"]; present { + t.Fatalf("expand = %#v, want omitted", body["expand"]) + } +} diff --git a/internal/jira/transport_test.go b/internal/jira/transport_test.go index 8edbc16..394e994 100644 --- a/internal/jira/transport_test.go +++ b/internal/jira/transport_test.go @@ -265,7 +265,7 @@ func TestJiraDownloadAttachmentContract(t *testing.T) { httpClient: server.Client(), limiter: rate.NewLimiter(rate.Inf, 1), } - body, contentType, err := client.DownloadAttachment(context.Background(), server.URL+"/attachment/1") + body, contentType, err := client.DownloadAttachment(context.Background(), "", server.URL+"/attachment/1") if err != nil { t.Fatalf("DownloadAttachment returned error: %v", err) } @@ -294,7 +294,7 @@ func TestJiraDownloadAttachmentContract(t *testing.T) { httpClient: server.Client(), limiter: rate.NewLimiter(rate.Inf, 1), } - _, _, err := client.DownloadAttachment(context.Background(), server.URL+"/attachment/1") + _, _, err := client.DownloadAttachment(context.Background(), "", server.URL+"/attachment/1") if !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } @@ -311,7 +311,7 @@ func TestJiraDownloadAttachmentContract(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := client.DownloadAttachment(ctx, server.URL+"/attachment/1") + _, _, err := client.DownloadAttachment(ctx, "", server.URL+"/attachment/1") if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } diff --git a/internal/jiraimport/job_status_test.go b/internal/jiraimport/job_status_test.go index 11e06c4..64af52d 100644 --- a/internal/jiraimport/job_status_test.go +++ b/internal/jiraimport/job_status_test.go @@ -3,6 +3,7 @@ package jiraimport import ( + "database/sql" "testing" "windshift/internal/blobstore" @@ -48,3 +49,66 @@ func TestCompletedWithErrorsIsTerminalAndAvailableForReimport(t *testing.T) { t.Fatalf("previous imports = %#v, want partial import job", previous) } } + +// TestTimedOutIsTerminalAndAvailableForReimport is the WI-1981 contract: an +// import stopped by the operator's configured time limit must be a terminal +// status with a clear message, release its worker lease, and remain available +// for a re-import that continues from the partial data. +func TestTimedOutIsTerminalAndAvailableForReimport(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer func() { _ = tdb.Close() }() + insertConnection(t, tdb, "connection-timeout-import") + insertJob(t, tdb, "job-timeout-import", "connection-timeout-import", "running") + service := New(tdb.Database, blobstore.Disabled()) + token := "timeout-worker" + if _, err := tdb.ExecWrite(`UPDATE jira_import_jobs SET lease_token = ? WHERE id = ?`, token, "job-timeout-import"); err != nil { + t.Fatalf("set job token: %v", err) + } + + progress := &Progress{ImportedIssues: 1800, TotalIssues: 2535} + message := "Import stopped at the configured time limit after importing 1800 of 2535 issues." + if err := service.UpdateStatus( + "job-timeout-import", + token, + "timed_out", + "timed_out", + progress, + message, + ); err != nil { + t.Fatalf("record timed out import: %v", err) + } + + status, err := service.GetJobStatus("job-timeout-import") + if err != nil { + t.Fatalf("get timed out status: %v", err) + } + if status.Status != "timed_out" || status.Phase != "timed_out" { + t.Fatalf("status = %q/%q, want timed_out/timed_out", status.Status, status.Phase) + } + if status.CompletedAt == nil { + t.Fatal("timed_out CompletedAt = nil, want terminal timestamp") + } + if status.ErrorMessage != message { + t.Fatalf("error message = %q, want %q", status.ErrorMessage, message) + } + if status.Progress == nil || status.Progress["imported_issues"] != float64(1800) || status.Progress["total_issues"] != float64(2535) { + t.Fatalf("progress = %#v, want the partial counts", status.Progress) + } + + var leaseToken sql.NullString + var leaseExpires sql.NullInt64 + if err := tdb.QueryRow(`SELECT lease_token, lease_expires_at FROM jira_import_jobs WHERE id = ?`, "job-timeout-import").Scan(&leaseToken, &leaseExpires); err != nil { + t.Fatalf("load lease: %v", err) + } + if leaseToken.Valid || leaseExpires.Valid { + t.Fatalf("lease token/expiry = %v/%v, want cleared for retry", leaseToken, leaseExpires) + } + + previous, err := service.PreviousImports([]string{"WS"}) + if err != nil { + t.Fatalf("list previous imports: %v", err) + } + if len(previous) != 1 || previous[0].JobID != "job-timeout-import" || previous[0].Status != "timed_out" { + t.Fatalf("previous imports = %#v, want the timed out job", previous) + } +} diff --git a/internal/llm/agent_transient_read_test.go b/internal/llm/agent_transient_read_test.go new file mode 100644 index 0000000..5c33e51 --- /dev/null +++ b/internal/llm/agent_transient_read_test.go @@ -0,0 +1,70 @@ +//go:build test + +package llm + +import ( + "context" + "fmt" + "testing" +) + +// WI-1802: an HTTP read that fails transiently must not be cached. The retry +// may succeed, and suppressing it as a duplicate would stall the loop as +// no_progress without the provider ever receiving a retry. +func TestRunAgentRetriesTransientHTTPReadFailures(t *testing.T) { + for _, status := range []int{429, 503} { + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { + client := &scriptedClient{responses: []*CompletionResponse{ + readCallResponse("call_1"), + readCallResponse("call_2"), + truncationResponse("Recovered.", "stop"), + }} + + executions := 0 + result, err := RunAgent(context.Background(), client, AgentConfig{ + SystemPrompt: "test", Tools: getItemTool(), MaxIterations: 5, + }, "review", func(_ context.Context, _ string, _ string) (string, error) { + executions++ + if executions == 1 { + return fmt.Sprintf(`{"body":"busy","status_code":%d}`, status), nil + } + return `{"title":"Widget"}`, nil + }, nil) + if err != nil { + t.Fatalf("RunAgent returned error: %v", err) + } + if executions != 2 { + t.Fatalf("transient HTTP read executed %d times, want 2 (the retry must not be suppressed)", executions) + } + if result.StopReason != StopReasonDone { + t.Fatalf("stop reason = %q, want %q", result.StopReason, StopReasonDone) + } + }) + } +} + +// A successful HTTP read is still cached: an identical repeat is suppressed. +func TestRunAgentCachesSuccessfulHTTPReads(t *testing.T) { + client := &scriptedClient{responses: []*CompletionResponse{ + readCallResponse("call_1"), + readCallResponse("call_2"), + truncationResponse("Reviewed.", "stop"), + }} + + executions := 0 + result, err := RunAgent(context.Background(), client, AgentConfig{ + SystemPrompt: "test", Tools: getItemTool(), MaxIterations: 5, + }, "review", func(_ context.Context, _ string, _ string) (string, error) { + executions++ + return `{"body":"ok","status_code":200}`, nil + }, nil) + if err != nil { + t.Fatalf("RunAgent returned error: %v", err) + } + if executions != 1 { + t.Fatalf("successful HTTP read executed %d times, want 1 (the duplicate must be suppressed)", executions) + } + if result.StopReason != StopReasonDone { + t.Fatalf("stop reason = %q, want %q", result.StopReason, StopReasonDone) + } +} diff --git a/internal/llm/catalog_claude_models_test.go b/internal/llm/catalog_claude_models_test.go new file mode 100644 index 0000000..0e99bb6 --- /dev/null +++ b/internal/llm/catalog_claude_models_test.go @@ -0,0 +1,47 @@ +//go:build test + +package llm + +import ( + "slices" + "strings" + "testing" +) + +// WI-1963: the support floor is Claude 4.7+, which all use adaptive thinking. +// The adapter always emits adaptive thinking for Anthropic, so a pre-4.7 model +// in the default catalog would be sent a request it rejects. Pin the catalog so +// those ids cannot be reintroduced silently. + +func TestDefaultCatalogOffersOnlyCurrentClaudeModels(t *testing.T) { + LoadDefaultProviders() + + anthropic := GetProvider(ProviderType("anthropic")) + if anthropic == nil { + t.Fatal("anthropic provider missing from the default catalog") + } + got := make([]string, 0, len(anthropic.Models)) + for _, m := range anthropic.Models { + got = append(got, m.ID) + } + slices.Sort(got) + want := []string{"claude-fable-5", "claude-opus-5", "claude-sonnet-5"} + if !slices.Equal(got, want) { + t.Fatalf("anthropic catalog models = %v, want the current set %v", got, want) + } + + // Anthropic models offered through a proxy must also be current. + var proxied []string + for _, p := range GetProviders() { + for _, m := range p.Models { + if strings.HasPrefix(m.ID, "anthropic/") { + proxied = append(proxied, m.ID) + } + } + } + slices.Sort(proxied) + wantProxied := []string{"anthropic/claude-opus-5", "anthropic/claude-sonnet-5"} + if !slices.Equal(proxied, wantProxied) { + t.Fatalf("proxied Anthropic catalog models = %v, want %v", proxied, wantProxied) + } +} diff --git a/internal/llm/connection_probe_test.go b/internal/llm/connection_probe_test.go new file mode 100644 index 0000000..1b701b1 --- /dev/null +++ b/internal/llm/connection_probe_test.go @@ -0,0 +1,95 @@ +//go:build test + +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "windshift/internal/utils" +) + +// The admin connection test must exercise the parameters in-product chat sends +// — sampling temperature and a tool definition — so a provider that rejects a +// chat request fails the test instead of passing a bare one-token ping. +func TestConnectionProbeSendsChatParameters(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + var body map[string]json.RawMessage + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + client := NewProviderClient(ConnectionConfig{ + ProviderType: ProviderType("local"), + Model: "opaque-litellm-model", + BaseURL: server.URL, + Timeout: time.Second, + }) + if err := client.Health(context.Background()); err != nil { + t.Fatalf("Health() error = %v", err) + } + + if got := string(body["temperature"]); got != "0.1" { + t.Fatalf("probe temperature = %s, want 0.1 (body=%s)", got, mustMarshalJSON(t, body)) + } + var tools []map[string]any + if err := json.Unmarshal(body["tools"], &tools); err != nil { + t.Fatalf("probe tools missing: %v (body=%s)", err, mustMarshalJSON(t, body)) + } + if len(tools) != 1 { + t.Fatalf("probe tools = %d, want 1 (body=%s)", len(tools), mustMarshalJSON(t, body)) + } + function, _ := tools[0]["function"].(map[string]any) + if function["name"] != "windshift_connection_probe" { + t.Fatalf("probe tool name = %v (body=%s)", function["name"], mustMarshalJSON(t, body)) + } +} + +// A connection with a configured reasoning level must have the probe exercise +// that option, including the adapter's rule that thinking drops temperature. +func TestConnectionProbeCarriesConfiguredReasoning(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + var body map[string]json.RawMessage + server := reasoningAnthropicServer(t, &body) + defer server.Close() + + client := NewProviderClient(ConnectionConfig{ + ProviderType: ProviderType("anthropic"), + BaseURL: server.URL, + Model: "claude-sonnet-5", + ProviderConfig: `{"reasoning_level":"high"}`, + Timeout: time.Second, + }) + if err := client.Health(context.Background()); err != nil { + t.Fatalf("Health() error = %v", err) + } + + var outputConfig map[string]json.RawMessage + if err := json.Unmarshal(body["output_config"], &outputConfig); err != nil { + t.Fatalf("output_config missing: %v (body=%s)", err, mustMarshalJSON(t, body)) + } + if got := string(outputConfig["effort"]); got != `"high"` { + t.Fatalf("output_config.effort = %s, want \"high\" (body=%s)", got, mustMarshalJSON(t, body)) + } + if _, ok := body["temperature"]; ok { + t.Fatalf("probe leaked temperature alongside configured thinking: %s", mustMarshalJSON(t, body)) + } + if _, ok := body["tools"]; !ok { + t.Fatalf("probe omitted tools: %s", mustMarshalJSON(t, body)) + } +} diff --git a/internal/llm/fallback_conservative_test.go b/internal/llm/fallback_conservative_test.go new file mode 100644 index 0000000..75f416b --- /dev/null +++ b/internal/llm/fallback_conservative_test.go @@ -0,0 +1,30 @@ +//go:build test + +package llm + +import "testing" + +// WI-1796: an unknown model's output cap must be conservative. A model that +// supports fewer output tokens than the fallback rejects the request outright, +// which is worse than truncating an unusually long answer. The fallback must +// therefore not exceed the smallest output limit any catalogued model +// advertises. +func TestUnknownModelOutputFallbackIsConservative(t *testing.T) { + LoadDefaultProviders() + + smallest := 0 + for _, p := range GetProviders() { + for _, m := range p.Models { + if m.MaxTokens > 0 && (smallest == 0 || m.MaxTokens < smallest) { + smallest = m.MaxTokens + } + } + } + if smallest == 0 { + t.Fatal("catalog advertises no output limits") + } + if FallbackMaxOutputTokens > smallest { + t.Fatalf("fallback output cap %d exceeds the smallest catalog limit %d; an unknown model supporting fewer tokens would reject the request", + FallbackMaxOutputTokens, smallest) + } +} diff --git a/internal/llm/fantasy_tools_schema_test.go b/internal/llm/fantasy_tools_schema_test.go new file mode 100644 index 0000000..247d9b0 --- /dev/null +++ b/internal/llm/fantasy_tools_schema_test.go @@ -0,0 +1,56 @@ +//go:build test + +package llm + +import ( + "encoding/json" + "testing" + + "charm.land/fantasy" +) + +// Externally supplied tool schemas — the coding-agent runner decodes its own +// CompletionRequest — never pass through the aitools registry, so the provider +// boundary is the only place they can be canonicalized. +func TestToFantasyToolsAddsPropertiesToExternallySuppliedObjectSchema(t *testing.T) { + tools := toFantasyTools([]ToolDefinition{{ + Type: "function", + Function: FunctionDef{ + Name: "read_file", + Description: "read a file", + Parameters: json.RawMessage(`{"type":"object","additionalProperties":false}`), + }, + }}, false) + if len(tools) != 1 { + t.Fatalf("tool count = %d, want 1", len(tools)) + } + ft, ok := tools[0].(fantasy.FunctionTool) + if !ok { + t.Fatalf("tool type = %T, want fantasy.FunctionTool", tools[0]) + } + properties, ok := ft.InputSchema["properties"] + if !ok { + t.Fatalf("provider-facing schema has no properties: %#v", ft.InputSchema) + } + if _, ok := properties.(map[string]any); !ok { + t.Fatalf("properties = %#v, want object", properties) + } +} + +func TestToFantasyToolsLeavesDeclaredPropertiesUntouched(t *testing.T) { + raw := `{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}` + tools := toFantasyTools([]ToolDefinition{{ + Type: "function", + Function: FunctionDef{ + Name: "read_file", + Parameters: json.RawMessage(raw), + }, + }}, false) + ft, ok := tools[0].(fantasy.FunctionTool) + if !ok { + t.Fatalf("tool type = %T, want fantasy.FunctionTool", tools[0]) + } + if _, ok := ft.InputSchema["properties"].(map[string]any)["path"]; !ok { + t.Fatalf("declared properties were altered: %#v", ft.InputSchema) + } +} diff --git a/internal/llm/finish_reason_test.go b/internal/llm/finish_reason_test.go new file mode 100644 index 0000000..f90a32f --- /dev/null +++ b/internal/llm/finish_reason_test.go @@ -0,0 +1,48 @@ +//go:build test + +package llm + +import ( + "testing" + + "charm.land/fantasy" +) + +// WI-1801: Fantasy emits the canonical finish reason "content-filter"; the +// adapter must normalize it to this package's "content_filter" spelling so the +// agent loop classifies a filtered response as StopReasonContentFilter instead +// of falling through to done/empty. + +func TestContentFilterFinishReasonReachesTheLoop(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "with partial text", content: "partial answer before the filter"}, + {name: "without text", content: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := normalizedFantasyResponse(tc.content, nil, nil, Usage{}, fantasy.FinishReasonContentFilter) + if got := resp.Choices[0].FinishReason; got != "content_filter" { + t.Fatalf("adapter finish reason = %q, want %q", got, "content_filter") + } + if got := finalStopReason(resp.Choices[0].FinishReason, tc.content); got != StopReasonContentFilter { + t.Fatalf("final stop reason = %q, want %q", got, StopReasonContentFilter) + } + }) + } +} + +func TestToolCallFinishReasonStillNormalizes(t *testing.T) { + resp := normalizedFantasyResponse("", nil, nil, Usage{}, fantasy.FinishReasonToolCalls) + if got := resp.Choices[0].FinishReason; got != "tool_calls" { + t.Fatalf("adapter finish reason = %q, want %q", got, "tool_calls") + } +} + +func TestUnfilteredFinishReasonStaysDone(t *testing.T) { + resp := normalizedFantasyResponse("answer", nil, nil, Usage{}, fantasy.FinishReasonStop) + if got := finalStopReason(resp.Choices[0].FinishReason, "answer"); got != StopReasonDone { + t.Fatalf("final stop reason = %q, want %q", got, StopReasonDone) + } +} diff --git a/internal/llm/model_limits_fallback_test.go b/internal/llm/model_limits_fallback_test.go index 153c781..9a1ea73 100644 --- a/internal/llm/model_limits_fallback_test.go +++ b/internal/llm/model_limits_fallback_test.go @@ -29,9 +29,9 @@ func TestApplyFallbackModelLimits(t *testing.T) { wantContext: fallbackContextWindow, wantOutput: FallbackMaxOutputTokens, wantApplied: true, }, { - name: "advertised context caps the output fallback", + name: "advertised context larger than the output fallback keeps the fallback", contextIn: 32000, outputIn: 0, - wantContext: 32000, wantOutput: 32000, wantApplied: true, + wantContext: 32000, wantOutput: FallbackMaxOutputTokens, wantApplied: true, }, { name: "output floor never exceeds a smaller advertised context", diff --git a/internal/llm/thinking_sampling_test.go b/internal/llm/thinking_sampling_test.go new file mode 100644 index 0000000..81c8827 --- /dev/null +++ b/internal/llm/thinking_sampling_test.go @@ -0,0 +1,117 @@ +//go:build test + +package llm + +import ( + "context" + "encoding/json" + "testing" + "time" + + "windshift/internal/utils" +) + +// WI-1798: Anthropic rejects temperature/top_p alongside thinking. Fantasy +// clears them only on its explicit-budget thinking path; the effort path, and +// an operator thinking override forwarded via ExtraBody, leave them set. The +// adapter must drop sampling parameters whenever thinking is enabled. + +func completeAnthropicCapturingBody(t *testing.T, providerConfig string, req CompletionRequest) map[string]json.RawMessage { + t.Helper() + var body map[string]json.RawMessage + server := reasoningAnthropicServer(t, &body) + defer server.Close() + + client := NewProviderClient(ConnectionConfig{ + ProviderType: ProviderType("anthropic"), + BaseURL: server.URL, + Model: "claude-sonnet-5", + ProviderConfig: providerConfig, + Timeout: time.Second, + }) + if _, err := client.Complete(context.Background(), req); err != nil { + t.Fatalf("Complete() error = %v", err) + } + return body +} + +func anthropicRequestWithTemperature() CompletionRequest { + return CompletionRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + Temperature: 0.2, + } +} + +func TestAnthropicConfiguredEffortDropsSamplingParameters(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + body := completeAnthropicCapturingBody(t, `{"reasoning_level":"high","top_p":0.9}`, anthropicRequestWithTemperature()) + if _, ok := body["temperature"]; ok { + t.Fatalf("temperature leaked alongside effort thinking: %s", mustMarshalJSON(t, body)) + } + if _, ok := body["top_p"]; ok { + t.Fatalf("top_p leaked alongside effort thinking: %s", mustMarshalJSON(t, body)) + } + var outputConfig map[string]json.RawMessage + if err := json.Unmarshal(body["output_config"], &outputConfig); err != nil { + t.Fatalf("output_config missing: %v (body=%s)", err, mustMarshalJSON(t, body)) + } + if got := string(outputConfig["effort"]); got != `"high"` { + t.Fatalf("output_config.effort = %s, want \"high\"", got) + } +} + +func TestAnthropicCodingAgentEffortDropsSamplingParameters(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + req := anthropicRequestWithTemperature() + req.CodingAgent = true + body := completeAnthropicCapturingBody(t, "", req) + if _, ok := body["temperature"]; ok { + t.Fatalf("temperature leaked alongside coding-agent effort: %s", mustMarshalJSON(t, body)) + } +} + +func TestAnthropicThinkingOverrideDropsSamplingParameters(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + body := completeAnthropicCapturingBody(t, `{"thinking":{"type":"enabled","budget_tokens":2048}}`, anthropicRequestWithTemperature()) + if _, ok := body["temperature"]; ok { + t.Fatalf("temperature leaked alongside operator thinking: %s", mustMarshalJSON(t, body)) + } + var thinking map[string]json.RawMessage + if err := json.Unmarshal(body["thinking"], &thinking); err != nil { + t.Fatalf("thinking missing: %v (body=%s)", err, mustMarshalJSON(t, body)) + } + if got := string(thinking["budget_tokens"]); got != "2048" { + t.Fatalf("thinking.budget_tokens = %s, want 2048", got) + } +} + +func TestAnthropicThinkingDisabledPreservesTemperature(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + body := completeAnthropicCapturingBody(t, "", anthropicRequestWithTemperature()) + if got := string(body["temperature"]); got != "0.2" { + t.Fatalf("temperature = %s, want 0.2 when thinking is disabled (body=%s)", got, mustMarshalJSON(t, body)) + } +} + +func TestAnthropicDisabledThinkingOverridePreservesTemperature(t *testing.T) { + LoadDefaultProviders() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(true) }) + + body := completeAnthropicCapturingBody(t, `{"thinking":{"type":"disabled"}}`, anthropicRequestWithTemperature()) + if got := string(body["temperature"]); got != "0.2" { + t.Fatalf("temperature = %s, want 0.2 when the thinking override is disabled (body=%s)", got, mustMarshalJSON(t, body)) + } +} diff --git a/internal/llm/url_test.go b/internal/llm/url_test.go index 1bb9c25..5e1838f 100644 --- a/internal/llm/url_test.go +++ b/internal/llm/url_test.go @@ -86,8 +86,8 @@ func TestDefaultProvidersExposeDirectAnthropic(t *testing.T) { if provider.AuthScheme() != "anthropic" || provider.ResponseFormat() != "anthropic" { t.Fatalf("catalog contract = auth %q response %q, want anthropic/anthropic", provider.AuthScheme(), provider.ResponseFormat()) } - if len(provider.Models) != 4 { - t.Fatalf("models = %d, want 4 current Anthropic models", len(provider.Models)) + if len(provider.Models) != 3 { + t.Fatalf("models = %d, want 3 current Anthropic models", len(provider.Models)) } for _, model := range provider.Models { if model.MaxTokens <= 0 || model.ContextWindow <= 0 || !model.SupportsVision { diff --git a/internal/logbook/action_service_test.go b/internal/logbook/action_service_test.go deleted file mode 100644 index d8642ff..0000000 --- a/internal/logbook/action_service_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package logbook - -import ( - "strings" - "testing" - - "windshift/internal/models" -) - -func TestExecuteActionRejectsDisabledAction(t *testing.T) { - t.Parallel() - - err := (&LogbookActionService{}).executeAction(&models.LogbookAction{ID: 6}, nil) - if err == nil || !strings.Contains(err.Error(), "disabled") { - t.Fatalf("error = %v, want disabled", err) - } -} diff --git a/internal/logbook/durable_action_consumer_test.go b/internal/logbook/durable_action_consumer_test.go deleted file mode 100644 index a1aeac7..0000000 --- a/internal/logbook/durable_action_consumer_test.go +++ /dev/null @@ -1,528 +0,0 @@ -//go:build test - -package logbook - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "testing" - "time" - - "windshift/internal/database" - "windshift/internal/events" - "windshift/internal/logbookevents" - "windshift/internal/models" - "windshift/internal/repository" - "windshift/internal/testutils" -) - -// handlerFunc adapts a function to events.Handler for inline registrations. -type handlerFunc func(context.Context, events.Event) error - -func (f handlerFunc) Handle(ctx context.Context, event events.Event) error { - return f(ctx, event) -} - -func TestLogbookIngestionCommitsReadyStateChunksAndEventsTogether(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - repo, doc := createLogbookDocumentFixture(t, db, "atomic-success") - recorder := NewDurableDocumentEventRecorder(db) - ingestion := NewIngestionService(repo, nil, recorder) - - if err := ingestion.IngestNote(context.Background(), doc.ID); err != nil { - t.Fatalf("IngestNote() error = %v", err) - } - loaded, err := repo.GetDocument(doc.ID) - if err != nil { - t.Fatalf("GetDocument() error = %v", err) - } - if loaded.Status != models.LogbookDocStatusReady || loaded.ChunkCount == 0 { - t.Fatalf("completed document = status:%q chunks:%d, want ready with chunks", loaded.Status, loaded.ChunkCount) - } - - rows, err := db.Query(` - SELECT event_type, aggregate_sequence, payload - FROM domain_events - WHERE aggregate_type = 'logbook_document' AND aggregate_id = ? - ORDER BY aggregate_sequence - `, doc.ID) - if err != nil { - t.Fatalf("query document events: %v", err) - } - defer rows.Close() - wantTypes := []string{logbookevents.Classified, DurableLogbookActionCompatibilityEvent} - for index, wantType := range wantTypes { - if !rows.Next() { - t.Fatalf("document event %d missing", index) - } - var eventType string - var sequence int64 - var payload []byte - if err := rows.Scan(&eventType, &sequence, &payload); err != nil { - t.Fatalf("scan document event %d: %v", index, err) - } - if eventType != wantType || sequence != int64(index+1) { - t.Fatalf("document event %d = %q sequence %d, want %q sequence %d", index, eventType, sequence, wantType, index+1) - } - if eventType == logbookevents.Classified { - var fact logbookevents.ClassifiedV1 - if err := json.Unmarshal(payload, &fact); err != nil { - t.Fatalf("decode classified fact: %v", err) - } - if fact.Document.ID != doc.ID || fact.Document.BucketID != doc.BucketID || fact.Document.ContentType != models.LogbookContentTypeKnowledge || fact.Document.MimeType != "text/markdown" { - t.Fatalf("classified fact document = %+v", fact.Document) - } - } - } - if rows.Next() { - t.Fatal("unexpected extra document event") - } -} - -func TestLogbookIngestionRollsBackReadyStateAndChunksWhenEventAppendFails(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - repo, doc := createLogbookDocumentFixture(t, db, "atomic-rollback") - ingestion := NewIngestionService(repo, nil, classifiedRecorderFunc(func(context.Context, database.Tx, logbookevents.ClassifiedInput) error { - return errForcedLogbookEventAppend - })) - - err := ingestion.IngestNote(context.Background(), doc.ID) - if !errors.Is(err, errForcedLogbookEventAppend) { - t.Fatalf("IngestNote() error = %v, want forced event append failure", err) - } - loaded, loadErr := repo.GetDocument(doc.ID) - if loadErr != nil { - t.Fatalf("GetDocument() error = %v", loadErr) - } - if loaded.Status == models.LogbookDocStatusReady || loaded.ChunkCount != 0 { - t.Fatalf("rolled-back document = status:%q chunks:%d, want non-ready with no chunks", loaded.Status, loaded.ChunkCount) - } - var eventCount int - if queryErr := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE aggregate_id = ?", doc.ID).Scan(&eventCount); queryErr != nil { - t.Fatalf("count rolled-back events: %v", queryErr) - } - if eventCount != 0 { - t.Fatalf("rolled-back event count = %d, want 0", eventCount) - } -} - -func TestDurableLogbookConsumerFreezesTargetsAndDeduplicatesExecution(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - _, doc := createLogbookDocumentFixture(t, db, "frozen-targets") - actionRepo := repository.NewLogbookActionRepository(db) - classifiedID := createLogbookAction(t, actionRepo, doc.BucketID, "classified", models.LogbookTriggerDocumentClassified, "") - keywordID := createLogbookAction(t, actionRepo, doc.BucketID, "keyword", models.LogbookTriggerContentKeyword, `{"keywords":["durable"],"keyword_mode":"all"}`) - mimeID := createLogbookAction(t, actionRepo, doc.BucketID, "mime", models.LogbookTriggerMimeType, `{"mime_types":["text/*"]}`) - service := NewLogbookActionService(db, actionRepo, "", "", "") - t.Cleanup(service.Stop) - service.InvalidateBucketCache(doc.BucketID) - consumer := NewDurableLogbookActionConsumer(db, service, nil) - event := classifiedLogbookDomainEvent(t, 71, "logbook-classified-71", doc, "durable systems need durable delivery") - - if err := consumer.Handle(context.Background(), event); err != nil { - t.Fatalf("first Handle() error = %v", err) - } - laterID := createLogbookAction(t, actionRepo, doc.BucketID, "enabled later", models.LogbookTriggerDocumentClassified, "") - service.InvalidateBucketCache(doc.BucketID) - if err := consumer.Handle(context.Background(), event); err != nil { - t.Fatalf("duplicate Handle() error = %v", err) - } - - rows, err := db.Query("SELECT action_id FROM action_event_targets WHERE event_key = ? ORDER BY action_id", event.Key) - if err != nil { - t.Fatalf("query frozen targets: %v", err) - } - defer rows.Close() - var gotIDs []int - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - t.Fatalf("scan frozen target: %v", err) - } - gotIDs = append(gotIDs, id) - } - wantIDs := map[int]bool{classifiedID: true, keywordID: true, mimeID: true} - if len(gotIDs) != len(wantIDs) { - t.Fatalf("frozen target IDs = %v, want classified/keyword/mime", gotIDs) - } - for _, id := range gotIDs { - if !wantIDs[id] || id == laterID { - t.Fatalf("unexpected frozen target %d in %v", id, gotIDs) - } - } - var executionCount int - if err := db.QueryRow("SELECT COUNT(*) FROM logbook_action_execution_logs WHERE durable_event_key = ?", event.Key).Scan(&executionCount); err != nil { - t.Fatalf("count durable executions: %v", err) - } - if executionCount != 3 { - t.Fatalf("durable execution count = %d, want 3", executionCount) - } -} - -func TestDurableLogbookCutoverStopsCompatibilityWithoutOmittingCanonicalFact(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - repo, doc := createLogbookDocumentFixture(t, db, "cutover") - ingress := NewDurableLogbookActionIngress(db) - cutover, err := ingress.ActivateCanonicalDocuments(context.Background()) - if err != nil { - t.Fatalf("ActivateCanonicalDocuments() error = %v", err) - } - ingestion := NewIngestionService(repo, nil, NewDurableDocumentEventRecorder(db)) - if err := ingestion.IngestNote(context.Background(), doc.ID); err != nil { - t.Fatalf("IngestNote() error = %v", err) - } - - var canonical, compatibility int - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", logbookevents.Classified).Scan(&canonical); err != nil { - t.Fatalf("count canonical facts: %v", err) - } - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableLogbookActionCompatibilityEvent).Scan(&compatibility); err != nil { - t.Fatalf("count compatibility facts: %v", err) - } - if canonical != 1 || compatibility != 0 { - t.Fatalf("post-cutover facts = canonical:%d compatibility:%d, want 1/0", canonical, compatibility) - } - if err := ConfigureDurableLogbookActionConsumers(context.Background(), events.NewStore(db), cutover); err != nil { - t.Fatalf("ConfigureDurableLogbookActionConsumers() error = %v", err) - } - var active bool - var startID int64 - if err := db.QueryRow("SELECT is_active, start_event_id FROM domain_event_consumers WHERE consumer_key = ?", DurableLogbookActionConsumerKey).Scan(&active, &startID); err != nil { - t.Fatalf("load canonical logbook consumer: %v", err) - } - if !active || startID != cutover.StartEventID { - t.Fatalf("canonical consumer = active:%v start:%d, want true/%d", active, startID, cutover.StartEventID) - } -} - -func TestDurableLogbookEngineRecoversEventsWrittenWhileStopped(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - repo, firstDoc := createLogbookDocumentFixture(t, db, "restart") - actionRepo := repository.NewLogbookActionRepository(db) - createLogbookAction(t, actionRepo, firstDoc.BucketID, "restart", models.LogbookTriggerDocumentClassified, "") - service := NewLogbookActionService(db, actionRepo, "", "", "") - t.Cleanup(service.Stop) - service.InvalidateBucketCache(firstDoc.BucketID) - recorder := NewDurableDocumentEventRecorder(db) - ingestion := NewIngestionService(repo, nil, recorder) - - config := logbookEventEngineTestConfig() - startEngine := func(activate bool) *events.Engine { - t.Helper() - engine := events.NewEngine(db, config) - if err := PrepareDurableLogbookActionEngine(context.Background(), engine, service, activate); err != nil { - t.Fatalf("prepare logbook engine: %v", err) - } - if err := engine.Start(context.Background()); err != nil { - t.Fatalf("start logbook engine: %v", err) - } - return engine - } - stopEngine := func(engine *events.Engine) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if err := engine.Shutdown(ctx); err != nil { - t.Fatalf("shutdown logbook engine: %v", err) - } - } - - firstEngine := startEngine(true) - if err := ingestion.IngestNote(context.Background(), firstDoc.ID); err != nil { - t.Fatalf("ingest first document: %v", err) - } - waitForLogbookExecutions(t, db, 1) - stopEngine(firstEngine) - - secondDoc := createLogbookDocumentInBucket(t, repo, firstDoc.BucketID, "Written while stopped") - if err := ingestion.IngestNote(context.Background(), secondDoc.ID); err != nil { - t.Fatalf("ingest document while stopped: %v", err) - } - assertLogbookExecutionCount(t, db, 1) - - secondEngine := startEngine(false) - t.Cleanup(func() { stopEngine(secondEngine) }) - waitForLogbookExecutions(t, db, 2) -} - -func TestDurableLogbookPoisonEventReplayAndSkipPreserveDocumentOrder(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - _, doc := createLogbookDocumentFixture(t, db, "poison") - actionRepo := repository.NewLogbookActionRepository(db) - createLogbookAction(t, actionRepo, doc.BucketID, "after poison", models.LogbookTriggerDocumentClassified, "") - service := NewLogbookActionService(db, actionRepo, "", "", "") - t.Cleanup(service.Stop) - service.InvalidateBucketCache(doc.BucketID) - - engine := events.NewEngine(db, logbookEventEngineTestConfig()) - if err := PrepareDurableLogbookActionEngine(context.Background(), engine, service, true); err != nil { - t.Fatalf("prepare logbook engine: %v", err) - } - badInput := classifiedLogbookEventInput(t, doc, "poison-v2") - badInput.PayloadVersion = 99 - bad, err := engine.AppendStandalone(context.Background(), badInput) - if err != nil { - t.Fatalf("append poison event: %v", err) - } - good, err := engine.AppendStandalone(context.Background(), classifiedLogbookEventInput(t, doc, "after-poison")) - if err != nil { - t.Fatalf("append ordered event: %v", err) - } - if bad.AggregateSequence != 1 || good.AggregateSequence != 2 { - t.Fatalf("aggregate sequences = %d/%d, want 1/2", bad.AggregateSequence, good.AggregateSequence) - } - if err := engine.Start(context.Background()); err != nil { - t.Fatalf("start logbook engine: %v", err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _ = engine.Shutdown(ctx) - }) - waitForDeliveryState(t, db, bad.ID, DurableLogbookActionConsumerKey, string(events.StateFailed)) - assertDeliveryState(t, db, good.ID, DurableLogbookActionConsumerKey, string(events.StatePending)) - - operator := events.Operator{Kind: "user", Ref: "1"} - if err := engine.Store().Replay(context.Background(), bad.ID, DurableLogbookActionConsumerKey, operator, "verify poison replay", time.Now().UTC()); err != nil { - t.Fatalf("Replay() error = %v", err) - } - waitForDeliveryState(t, db, bad.ID, DurableLogbookActionConsumerKey, string(events.StateFailed)) - if err := engine.Store().Skip(context.Background(), bad.ID, DurableLogbookActionConsumerKey, operator, "invalid payload version", time.Now().UTC()); err != nil { - t.Fatalf("Skip() error = %v", err) - } - waitForDeliveryState(t, db, good.ID, DurableLogbookActionConsumerKey, string(events.StateCompleted)) - waitForLogbookExecutions(t, db, 1) - - var auditActions int - if err := db.QueryRow(` - SELECT COUNT(*) FROM domain_event_delivery_actions - WHERE event_id = ? AND consumer_key = ? AND action IN ('replay', 'skip') - `, bad.ID, DurableLogbookActionConsumerKey).Scan(&auditActions); err != nil { - t.Fatalf("count replay/skip audit actions: %v", err) - } - if auditActions != 2 { - t.Fatalf("replay/skip audit actions = %d, want 2", auditActions) - } -} - -func TestLogbookDurableEngineShutdownIsBounded(t *testing.T) { - tdb := newLogbookPostgresTestDB(t) - db := tdb.GetDatabase() - store := events.NewStore(db) - const consumerKey = "logbook.shutdown.test.v1" - if err := store.ConfigureConsumer(context.Background(), events.Consumer{ - Key: consumerKey, HandlerVersion: 1, Active: true, StartEventID: 1, - EventTypes: []string{"logbook.shutdown.v1"}, - }); err != nil { - t.Fatalf("configure shutdown consumer: %v", err) - } - input := events.NewEvent{ - Key: "logbook-shutdown-event", AggregateType: "logbook_document", AggregateID: "shutdown-doc", - Type: "logbook.shutdown.v1", PayloadVersion: 1, OccurredAt: time.Now().UTC(), - ActorKind: "system", SourceKind: "test", Payload: json.RawMessage(`{"ok":true}`), - } - if _, err := store.AppendStandalone(context.Background(), input); err != nil { - t.Fatalf("append shutdown event: %v", err) - } - - started := make(chan struct{}) - release := make(chan struct{}) - engine := events.NewEngine(db, logbookEventEngineTestConfig()) - if err := engine.RegisterHandler(consumerKey, handlerFunc(func(context.Context, events.Event) error { - close(started) - <-release - return nil - })); err != nil { - t.Fatalf("register blocking handler: %v", err) - } - if err := engine.Start(context.Background()); err != nil { - t.Fatalf("start blocking engine: %v", err) - } - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("blocking handler did not start") - } - canceled, cancel := context.WithCancel(context.Background()) - cancel() - if err := engine.Shutdown(canceled); !errors.Is(err, context.Canceled) { - t.Fatalf("bounded Shutdown() error = %v, want context.Canceled", err) - } - close(release) - ctx, stop := context.WithTimeout(context.Background(), 3*time.Second) - defer stop() - if err := engine.Shutdown(ctx); err != nil { - t.Fatalf("drained Shutdown() error = %v", err) - } -} - -type classifiedRecorderFunc func(context.Context, database.Tx, logbookevents.ClassifiedInput) error - -func (f classifiedRecorderFunc) RecordClassified(ctx context.Context, tx database.Tx, input logbookevents.ClassifiedInput) error { - return f(ctx, tx, input) -} - -var errForcedLogbookEventAppend = errors.New("forced event append failure") - -func newLogbookPostgresTestDB(t *testing.T) *testutils.TestDB { - t.Helper() - if !testutils.IsPostgres() { - t.Skip("logbook sidecar uses PostgreSQL") - } - tdb := testutils.CreateTestDB(t, true) - if err := InitializeSchema(tdb.GetDatabase()); err != nil { - t.Fatalf("InitializeSchema() error = %v", err) - } - return tdb -} - -func createLogbookDocumentFixture(t *testing.T, db database.Database, suffix string) (*Repository, *models.LogbookDocument) { - t.Helper() - repo := NewRepository(db) - bucket, err := repo.CreateBucket(models.LogbookBucketCreateRequest{Name: "Durable " + suffix}, 1) - if err != nil { - t.Fatalf("create logbook bucket: %v", err) - } - doc := createLogbookDocumentInBucket(t, repo, bucket.ID, "Durable note") - return repo, doc -} - -func createLogbookDocumentInBucket(t *testing.T, repo *Repository, bucketID, title string) *models.LogbookDocument { - t.Helper() - doc := &models.LogbookDocument{ - BucketID: bucketID, Title: title, SourceType: models.LogbookSourceNote, - RawContent: "durable systems need durable delivery", MimeType: "text/markdown", - Status: models.LogbookDocStatusPending, CreatedBy: 1, - } - if err := repo.CreateDocument(doc); err != nil { - t.Fatalf("create logbook document: %v", err) - } - return doc -} - -func createLogbookAction(t *testing.T, repo *repository.LogbookActionRepository, bucketID, name string, trigger models.LogbookActionTriggerType, config string) int { - t.Helper() - createdBy := 1 - id, err := repo.Create(&models.LogbookAction{ - BucketID: bucketID, Name: name, IsEnabled: true, TriggerType: trigger, - TriggerConfig: config, CreatedBy: &createdBy, - }) - if err != nil { - t.Fatalf("create logbook action %q: %v", name, err) - } - return id -} - -func classifiedLogbookDomainEvent(t *testing.T, id int64, key string, doc *models.LogbookDocument, rawContent string) events.Event { - t.Helper() - payload, err := json.Marshal(logbookevents.ClassifiedV1{Document: logbookevents.DocumentSnapshot{ - ID: doc.ID, BucketID: doc.BucketID, Title: doc.Title, - ContentType: models.LogbookContentTypeKnowledge, MimeType: "text/markdown", - SourceType: doc.SourceType, Author: doc.Author, RawContent: rawContent, - }}) - if err != nil { - t.Fatalf("marshal classified event: %v", err) - } - return events.Event{ - ID: id, Key: key, AggregateType: "logbook_document", AggregateID: doc.ID, - AggregateSequence: 1, Type: logbookevents.Classified, PayloadVersion: logbookevents.PayloadVersion, - OccurredAt: time.Date(2026, time.August, 27, 14, 0, 0, 0, time.UTC), - ActorKind: "user", ActorRef: fmt.Sprint(doc.CreatedBy), SourceKind: "logbook", Payload: payload, - } -} - -func classifiedLogbookEventInput(t *testing.T, doc *models.LogbookDocument, key string) events.NewEvent { - t.Helper() - event := classifiedLogbookDomainEvent(t, 0, key, doc, doc.RawContent) - return events.NewEvent{ - Key: event.Key, AggregateType: event.AggregateType, AggregateID: event.AggregateID, - Type: event.Type, PayloadVersion: event.PayloadVersion, OccurredAt: event.OccurredAt, - ActorKind: event.ActorKind, ActorRef: event.ActorRef, SourceKind: event.SourceKind, Payload: event.Payload, - } -} - -func logbookEventEngineTestConfig() events.Config { - config := events.DefaultConfig() - config.WorkerCount = 2 - config.PollInterval = 5 * time.Millisecond - config.HandlerTimeout = time.Second - config.LeaseDuration = 2 * time.Second - config.MaxAttempts = 1 - return config -} - -func waitForLogbookExecutions(t *testing.T, db database.Database, want int) { - t.Helper() - waitForLogbookCondition(t, func() (bool, string) { - var count int - if err := db.QueryRow("SELECT COUNT(*) FROM logbook_action_execution_logs WHERE durable_event_key IS NOT NULL").Scan(&count); err != nil { - t.Fatalf("count durable logbook executions: %v", err) - } - return count == want, fmt.Sprintf("execution count %d, want %d", count, want) - }) -} - -func assertLogbookExecutionCount(t *testing.T, db database.Database, want int) { - t.Helper() - var count int - if err := db.QueryRow("SELECT COUNT(*) FROM logbook_action_execution_logs WHERE durable_event_key IS NOT NULL").Scan(&count); err != nil { - t.Fatalf("count durable logbook executions: %v", err) - } - if count != want { - t.Fatalf("durable logbook execution count = %d, want %d", count, want) - } -} - -func waitForDeliveryState(t *testing.T, db database.Database, eventID int64, consumerKey, want string) { - t.Helper() - waitForLogbookCondition(t, func() (bool, string) { - var state string - err := db.QueryRow("SELECT state FROM domain_event_deliveries WHERE event_id = ? AND consumer_key = ?", eventID, consumerKey).Scan(&state) - if errors.Is(err, sql.ErrNoRows) { - return false, "delivery not materialized" - } - if err != nil { - t.Fatalf("load delivery state: %v", err) - } - return state == want, fmt.Sprintf("delivery state %q, want %q", state, want) - }) -} - -func assertDeliveryState(t *testing.T, db database.Database, eventID int64, consumerKey, want string) { - t.Helper() - var state string - if err := db.QueryRow("SELECT state FROM domain_event_deliveries WHERE event_id = ? AND consumer_key = ?", eventID, consumerKey).Scan(&state); err != nil { - t.Fatalf("load delivery state: %v", err) - } - if state != want { - t.Fatalf("delivery state = %q, want %q", state, want) - } -} - -func waitForLogbookCondition(t *testing.T, condition func() (bool, string)) { - t.Helper() - deadline := time.NewTimer(5 * time.Second) - defer deadline.Stop() - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - for { - ok, detail := condition() - if ok { - return - } - select { - case <-deadline.C: - t.Fatal(detail) - case <-ticker.C: - } - } -} diff --git a/internal/logbook/ingestion_test.go b/internal/logbook/ingestion_test.go deleted file mode 100644 index 3005bfa..0000000 --- a/internal/logbook/ingestion_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package logbook - -import ( - "sync" - "testing" -) - -func TestLockDocEvictsEntryWhenLastHolderReleases(t *testing.T) { - s := &IngestionService{docLocks: make(map[string]*docLockEntry)} - - release := s.lockDoc("doc-1") - if got := len(s.docLocks); got != 1 { - t.Fatalf("after acquire, want len(docLocks)=1, got %d", got) - } - release() - if got := len(s.docLocks); got != 0 { - t.Fatalf("after release, want len(docLocks)=0, got %d", got) - } -} - -func TestLockDocManyDistinctDocsDoNotAccumulate(t *testing.T) { - s := &IngestionService{docLocks: make(map[string]*docLockEntry)} - - for i := 0; i < 1000; i++ { - release := s.lockDoc("doc") - release() - } - if got := len(s.docLocks); got != 0 { - t.Fatalf("after 1000 sequential acquire/release pairs, want len(docLocks)=0, got %d", got) - } - - for i := 0; i < 1000; i++ { - docID := "doc-" + string(rune('A'+(i%26))) + "-" + itoa(i) - release := s.lockDoc(docID) - release() - } - if got := len(s.docLocks); got != 0 { - t.Fatalf("after 1000 distinct docID acquire/release pairs, want len(docLocks)=0, got %d", got) - } -} - -func TestLockDocSerializesConcurrentHoldersAndCleansUp(t *testing.T) { - s := &IngestionService{docLocks: make(map[string]*docLockEntry)} - - const concurrency = 50 - var wg sync.WaitGroup - var mu sync.Mutex - var inCrit int - var maxInCrit int - - for i := 0; i < concurrency; i++ { - wg.Add(1) - go func() { - defer wg.Done() - release := s.lockDoc("hot-doc") - defer release() - mu.Lock() - inCrit++ - if inCrit > maxInCrit { - maxInCrit = inCrit - } - mu.Unlock() - mu.Lock() - inCrit-- - mu.Unlock() - }() - } - wg.Wait() - - if maxInCrit != 1 { - t.Fatalf("lock did not serialize: maxInCrit=%d, want 1", maxInCrit) - } - if got := len(s.docLocks); got != 0 { - t.Fatalf("after concurrent acquire/release, want len(docLocks)=0, got %d", got) - } -} - -// itoa avoids importing strconv just for a test helper. -func itoa(i int) string { - if i == 0 { - return "0" - } - neg := false - if i < 0 { - neg = true - i = -i - } - var buf [20]byte - pos := len(buf) - for i > 0 { - pos-- - buf[pos] = byte('0' + i%10) - i /= 10 - } - if neg { - pos-- - buf[pos] = '-' - } - return string(buf[pos:]) -} diff --git a/internal/logbook/thumbnail_guard_test.go b/internal/logbook/thumbnail_guard_test.go deleted file mode 100644 index 91bc790..0000000 --- a/internal/logbook/thumbnail_guard_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package logbook - -import ( - "bytes" - "hash/crc32" - "os" - "path/filepath" - "strings" - "testing" -) - -// pngHeaderWithDimensions writes a PNG whose IHDR declares the given -// dimensions without any pixel data, enough for a DecodeConfig-based guard. -func pngHeaderWithDimensions(t *testing.T, dir string, width, height uint32) string { - t.Helper() - buf := bytes.NewBuffer([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) - chunk := make([]byte, 4+4+13) - copy(chunk, []byte{0, 0, 0, 13}) - copy(chunk[4:], "IHDR") - chunk[8], chunk[9], chunk[10], chunk[11] = byte(width>>24), byte(width>>16), byte(width>>8), byte(width) - chunk[12], chunk[13], chunk[14], chunk[15] = byte(height>>24), byte(height>>16), byte(height>>8), byte(height) - chunk[16] = 8 - chunk[17] = 2 - crc := crc32.ChecksumIEEE(chunk[4:]) - buf.Write(chunk) - buf.Write([]byte{byte(crc >> 24), byte(crc >> 16), byte(crc >> 8), byte(crc)}) - path := filepath.Join(dir, "bomb.png") - if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { - t.Fatalf("write bomb header: %v", err) - } - return path -} - -// TestDecodeImageRejectsPixelBomb pins the pixel-bomb guard on ingested -// documents: oversized declared dimensions are rejected before image.Decode -// allocates memory proportional to those dimensions. -func TestDecodeImageRejectsPixelBomb(t *testing.T) { - dir := t.TempDir() - bombPath := pngHeaderWithDimensions(t, dir, 60_000, 60_000) - - _, err := decodeImage(bombPath) - if err == nil { - t.Fatal("decodeImage accepted a 3.6 gigapixel header") - } - if !strings.Contains(err.Error(), "pixel limit") { - t.Fatalf("error = %v, want the pixel-limit message", err) - } -} diff --git a/internal/logbookapi/durable_engine_test.go b/internal/logbookapi/durable_engine_test.go deleted file mode 100644 index 3604e5e..0000000 --- a/internal/logbookapi/durable_engine_test.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build test - -package logbookapi - -import ( - "context" - "testing" - "time" - - "windshift/internal/logbook" - "windshift/internal/testutils" -) - -func TestServerStartsSharedLogbookEventEngineAndStopsWithinContext(t *testing.T) { - if !testutils.IsPostgres() { - t.Skip("logbook sidecar uses PostgreSQL") - } - tdb := testutils.CreateTestDB(t, true) - server, err := NewServer(tdb.GetDatabase(), ServerConfig{ - StoragePath: t.TempDir(), MainServerSecret: "durable-logbook-test-secret", - ActivateDurableActions: true, - }, nil) - if err != nil { - t.Fatalf("NewServer() error = %v", err) - } - var consumers int - if err := tdb.QueryRow(` - SELECT COUNT(*) FROM domain_event_consumers - WHERE consumer_key IN (?, ?) AND is_active = true - `, logbook.DurableLogbookCompatibilityConsumerKey, logbook.DurableLogbookActionConsumerKey).Scan(&consumers); err != nil { - t.Fatalf("count active logbook consumers: %v", err) - } - if consumers != 2 { - t.Fatalf("active logbook consumers = %d, want 2", consumers) - } - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if err := server.Stop(ctx); err != nil { - t.Fatalf("Stop() error = %v", err) - } -} diff --git a/internal/logbookapi/guard_test.go b/internal/logbookapi/guard_test.go deleted file mode 100644 index e1fdc1d..0000000 --- a/internal/logbookapi/guard_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package logbookapi - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" -) - -// fakePermissionService implements the unexported bucketPermissionService -// interface so guard behavior can be tested without a real database. -type fakePermissionService struct { - hasBucketPermission func(userID int, isAdmin bool, groupIDs []int, bucketID, permission string) (bool, error) - accessibleBucketIDs func(userID int, isAdmin bool, groupIDs []int) ([]string, error) -} - -func (f *fakePermissionService) HasBucketPermission(userID int, isAdmin bool, groupIDs []int, bucketID, permission string) (bool, error) { - if f.hasBucketPermission != nil { - return f.hasBucketPermission(userID, isAdmin, groupIDs, bucketID, permission) - } - return false, nil -} - -func (f *fakePermissionService) GetAccessibleBucketIDs(userID int, isAdmin bool, groupIDs []int) ([]string, error) { - if f.accessibleBucketIDs != nil { - return f.accessibleBucketIDs(userID, isAdmin, groupIDs) - } - return nil, nil -} - -func requestWithUser(user *LogbookUser, path string) *http.Request { - r := httptest.NewRequest(http.MethodGet, path, nil) - if user != nil { - ctx := context.WithValue(r.Context(), logbookUserKey{}, user) - r = r.WithContext(ctx) - } - return r -} - -func TestRequireBucket_NoUser(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{} - req := requestWithUser(nil, "/api/logbook/buckets/123") - req.SetPathValue("bucketID", "550e8400-e29b-41d4-a716-446655440000") - - _, _, ok := requireBucketView(rec, req, perms) - if ok { - t.Fatal("expected guard to fail without a user") - } - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestRequireBucket_MalformedBucketID(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{} - req := requestWithUser(&LogbookUser{ID: 1}, "/api/logbook/buckets/not-a-uuid") - req.SetPathValue("bucketID", "not-a-uuid") - - _, _, ok := requireBucketView(rec, req, perms) - if ok { - t.Fatal("expected guard to fail for malformed bucket ID") - } - if rec.Code != http.StatusNotFound { - t.Fatalf("want 404, got %d", rec.Code) - } -} - -func TestRequireBucket_PermissionError(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - hasBucketPermission: func(int, bool, []int, string, string) (bool, error) { - return false, errors.New("database unavailable") - }, - } - req := requestWithUser(&LogbookUser{ID: 1}, "/api/logbook/buckets/550e8400-e29b-41d4-a716-446655440000") - req.SetPathValue("bucketID", "550e8400-e29b-41d4-a716-446655440000") - - _, _, ok := requireBucketView(rec, req, perms) - if ok { - t.Fatal("expected guard to fail on permission error") - } - if rec.Code != http.StatusInternalServerError { - t.Fatalf("want 500, got %d", rec.Code) - } -} - -func TestRequireBucket_Denied(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - hasBucketPermission: func(int, bool, []int, string, string) (bool, error) { - return false, nil - }, - } - req := requestWithUser(&LogbookUser{ID: 1}, "/api/logbook/buckets/550e8400-e29b-41d4-a716-446655440000") - req.SetPathValue("bucketID", "550e8400-e29b-41d4-a716-446655440000") - - _, _, ok := requireBucketView(rec, req, perms) - if ok { - t.Fatal("expected guard to fail when permission denied") - } - if rec.Code != http.StatusNotFound { - t.Fatalf("want 404 (not 403) to avoid existence leak, got %d", rec.Code) - } -} - -func TestRequireBucket_AdminBypass(t *testing.T) { - rec := httptest.NewRecorder() - bucketID := "550e8400-e29b-41d4-a716-446655440000" - perms := &fakePermissionService{ - hasBucketPermission: func(_ int, isAdmin bool, _ []int, id string, perm string) (bool, error) { - if !isAdmin { - t.Fatalf("expected admin to short-circuit permission check") - } - if id != bucketID { - t.Fatalf("want bucketID %s, got %s", bucketID, id) - } - return true, nil - }, - } - req := requestWithUser(&LogbookUser{ID: 1, IsAdmin: true}, "/api/logbook/buckets/"+bucketID) - req.SetPathValue("bucketID", bucketID) - - user, gotID, ok := requireBucketView(rec, req, perms) - if !ok { - t.Fatal("expected admin to pass") - } - if gotID != bucketID { - t.Fatalf("want bucketID %s, got %s", bucketID, gotID) - } - if user == nil || user.ID != 1 { - t.Fatalf("user not returned correctly: %+v", user) - } -} - -func TestRequireBucket_GroupPermission(t *testing.T) { - rec := httptest.NewRecorder() - bucketID := "550e8400-e29b-41d4-a716-446655440000" - perms := &fakePermissionService{ - hasBucketPermission: func(_ int, _ bool, groupIDs []int, id string, _ string) (bool, error) { - if id != bucketID { - t.Fatalf("want bucketID %s, got %s", bucketID, id) - } - for _, g := range groupIDs { - if g == 7 { - return true, nil - } - } - return false, nil - }, - } - req := requestWithUser(&LogbookUser{ID: 1, GroupIDs: []int{7, 8}}, "/api/logbook/buckets/"+bucketID) - req.SetPathValue("bucketID", bucketID) - - _, gotID, ok := requireBucketEdit(rec, req, perms) - if !ok { - t.Fatal("expected group permission to pass") - } - if gotID != bucketID { - t.Fatalf("want bucketID %s, got %s", bucketID, gotID) - } -} - -func TestRequireBucket_PermissionSpecificity(t *testing.T) { - rec := httptest.NewRecorder() - bucketID := "550e8400-e29b-41d4-a716-446655440000" - seen := map[string]bool{} - perms := &fakePermissionService{ - hasBucketPermission: func(_ int, _ bool, _ []int, id string, perm string) (bool, error) { - if id == bucketID { - seen[perm] = true - } - return true, nil - }, - } - req := requestWithUser(&LogbookUser{ID: 1}, "/api/logbook/buckets/"+bucketID) - req.SetPathValue("bucketID", bucketID) - - requireBucketAdmin(rec, req, perms) - if !seen["bucket.admin"] { - t.Fatal("admin guard did not request bucket.admin permission") - } -} - -func TestRequireAccessibleBuckets_NoUser(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{} - req := requestWithUser(nil, "/api/logbook/documents") - - _, ok := requireAccessibleBuckets(rec, req, perms) - if ok { - t.Fatal("expected accessible-bucket guard to fail without a user") - } - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestRequireAccessibleBuckets_Error(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - accessibleBucketIDs: func(int, bool, []int) ([]string, error) { - return nil, errors.New("database unavailable") - }, - } - req := requestWithUser(&LogbookUser{ID: 1}, "/api/logbook/documents") - - _, ok := requireAccessibleBuckets(rec, req, perms) - if ok { - t.Fatal("expected accessible-bucket guard to fail on error") - } - if rec.Code != http.StatusInternalServerError { - t.Fatalf("want 500, got %d", rec.Code) - } -} - -func TestRequireAccessibleBuckets_AdminBypass(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - accessibleBucketIDs: func(_ int, isAdmin bool, _ []int) ([]string, error) { - if !isAdmin { - t.Fatal("expected admin flag to be passed through") - } - return []string{"b1", "b2"}, nil - }, - } - req := requestWithUser(&LogbookUser{ID: 1, IsAdmin: true}, "/api/logbook/documents") - - ids, ok := requireAccessibleBuckets(rec, req, perms) - if !ok { - t.Fatal("expected accessible-bucket guard to pass") - } - if len(ids) != 2 || ids[0] != "b1" || ids[1] != "b2" { - t.Fatalf("unexpected ids: %v", ids) - } -} - -func TestRequireSystemAdmin_NonAdmin(t *testing.T) { - rec := httptest.NewRecorder() - req := requestWithUser(&LogbookUser{ID: 1, IsAdmin: false}, "/api/logbook/buckets") - - _, ok := requireSystemAdmin(rec, req) - if ok { - t.Fatal("expected non-admin to be rejected") - } - if rec.Code != http.StatusForbidden { - t.Fatalf("want 403, got %d", rec.Code) - } -} - -func TestRequireSystemAdmin_Admin(t *testing.T) { - rec := httptest.NewRecorder() - req := requestWithUser(&LogbookUser{ID: 1, IsAdmin: true}, "/api/logbook/buckets") - - user, ok := requireSystemAdmin(rec, req) - if !ok { - t.Fatal("expected admin to pass") - } - if user == nil || !user.IsAdmin { - t.Fatalf("user not returned correctly: %+v", user) - } -} - -func TestRequireBucketAccessForUser_PropagatesErrorAndDenial(t *testing.T) { - bucketID := "550e8400-e29b-41d4-a716-446655440000" - user := &LogbookUser{ID: 1} - - t.Run("error", func(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - hasBucketPermission: func(int, bool, []int, string, string) (bool, error) { - return false, errors.New("boom") - }, - } - if requireBucketAccessForUser(rec, requestWithUser(user, "/"), perms, user, bucketID, "bucket.view") { - t.Fatal("expected false on permission error") - } - if rec.Code != http.StatusInternalServerError { - t.Fatalf("want 500, got %d", rec.Code) - } - }) - - t.Run("denied", func(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - hasBucketPermission: func(int, bool, []int, string, string) (bool, error) { - return false, nil - }, - } - if requireBucketAccessForUser(rec, requestWithUser(user, "/"), perms, user, bucketID, "bucket.view") { - t.Fatal("expected false when denied") - } - if rec.Code != http.StatusNotFound { - t.Fatalf("want 404, got %d", rec.Code) - } - }) - - t.Run("allowed", func(t *testing.T) { - rec := httptest.NewRecorder() - perms := &fakePermissionService{ - hasBucketPermission: func(int, bool, []int, string, string) (bool, error) { - return true, nil - }, - } - if !requireBucketAccessForUser(rec, requestWithUser(user, "/"), perms, user, bucketID, "bucket.view") { - t.Fatal("expected true when allowed") - } - if rec.Code != http.StatusOK { - t.Fatalf("expected recorder left at default 200, got %d", rec.Code) - } - }) -} diff --git a/internal/logbookapi/header_auth_test.go b/internal/logbookapi/header_auth_test.go deleted file mode 100644 index 0f595b6..0000000 --- a/internal/logbookapi/header_auth_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package logbookapi - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "testing" - "time" - - "windshift/internal/logbookauth" -) - -const testSecret = "test-secret-dont-use-in-prod" - -// signedRequest builds a request with a valid signature for the given claims. -// Fields default to a well-formed admin user — override by mutating the -// returned claims before calling sign. -func signedRequest(t *testing.T, secret string, tsOffset time.Duration, overrides func(*logbookauth.Claims)) *http.Request { - t.Helper() - claims := logbookauth.Claims{ - Timestamp: time.Now().Add(tsOffset).Unix(), - Method: http.MethodGet, - Path: "/api/logbook/buckets", - Nonce: fmt.Sprintf("nonce-%d", time.Now().UnixNano()), - UserID: "42", - Email: "user@example.com", - FirstName: "Ada", - LastName: "Lovelace", - IsAdmin: "true", - GroupIDs: "1,2,3", - } - if overrides != nil { - overrides(&claims) - } - req := httptest.NewRequest(claims.Method, claims.Path, nil) - req.Header.Set("X-Logbook-User-ID", claims.UserID) - req.Header.Set("X-Logbook-User-Email", claims.Email) - req.Header.Set("X-Logbook-User-First-Name", claims.FirstName) - req.Header.Set("X-Logbook-User-Last-Name", claims.LastName) - req.Header.Set("X-Logbook-Is-Admin", claims.IsAdmin) - req.Header.Set("X-Logbook-Group-IDs", claims.GroupIDs) - req.Header.Set(logbookauth.HeaderTimestamp, strconv.FormatInt(claims.Timestamp, 10)) - req.Header.Set(logbookauth.HeaderNonce, claims.Nonce) - req.Header.Set(logbookauth.HeaderSignature, logbookauth.Sign(secret, claims)) - return req -} - -func runMiddleware(t *testing.T, secret string, req *http.Request) (*httptest.ResponseRecorder, *LogbookUser) { - t.Helper() - return runMiddlewareWithCache(t, secret, newNonceCache(logbookauth.MaxSkew, nonceCacheSize), req) -} - -func runMiddlewareWithCache(t *testing.T, secret string, cache *nonceCache, req *http.Request) (*httptest.ResponseRecorder, *LogbookUser) { - t.Helper() - var captured *LogbookUser - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = GetLogbookUser(r) - w.WriteHeader(http.StatusOK) - }) - rec := httptest.NewRecorder() - headerAuthMiddlewareWithCache(secret, cache, inner).ServeHTTP(rec, req) - return rec, captured -} - -func TestHeaderAuth_ValidSignatureAccepted(t *testing.T) { - rec, user := runMiddleware(t, testSecret, signedRequest(t, testSecret, 0, nil)) - if rec.Code != http.StatusOK { - t.Fatalf("want 200, got %d — body=%s", rec.Code, rec.Body.String()) - } - if user == nil || user.ID != 42 || !user.IsAdmin { - t.Fatalf("user not populated correctly: %+v", user) - } - if len(user.GroupIDs) != 3 { - t.Fatalf("want 3 group ids, got %v", user.GroupIDs) - } -} - -func TestHeaderAuth_MissingSignatureRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - req.Header.Del(logbookauth.HeaderSignature) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_MissingTimestampRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - req.Header.Del(logbookauth.HeaderTimestamp) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_MissingNonceRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - req.Header.Del(logbookauth.HeaderNonce) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_WrongSecretRejected(t *testing.T) { - // Request is signed with testSecret but the middleware runs with a - // different secret — must reject. - req := signedRequest(t, testSecret, 0, nil) - rec, _ := runMiddleware(t, "different-secret", req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_TamperedHeaderRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - // Elevate to admin=false → true by flipping header after signing. - // Claims were signed as admin=true; change email to something unsigned. - req.Header.Set("X-Logbook-User-Email", "attacker@evil.example") - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_StaleTimestampRejected(t *testing.T) { - req := signedRequest(t, testSecret, -(logbookauth.MaxSkew + 10*time.Second), nil) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_FutureTimestampRejected(t *testing.T) { - req := signedRequest(t, testSecret, logbookauth.MaxSkew+10*time.Second, nil) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_InvalidUserIDRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, func(c *logbookauth.Claims) { - c.UserID = "-1" - }) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_NonHexSignatureRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - req.Header.Set(logbookauth.HeaderSignature, "v2=not-hex") - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_WrongVersionPrefixRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - // Replace current version prefix with "v1=" — verifier must refuse any - // version other than the current one, including old schemes. - sig := req.Header.Get(logbookauth.HeaderSignature) - req.Header.Set(logbookauth.HeaderSignature, "v1="+sig[len(logbookauth.SignatureVersion+"="):]) - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_TamperedPathRejected(t *testing.T) { - // Sign one path, send the same headers to a different path. Because path - // is part of the canonical, the signature must not verify. - req := signedRequest(t, testSecret, 0, nil) - req.URL.Path = "/api/logbook/documents/attacker-chose" - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_TamperedMethodRejected(t *testing.T) { - req := signedRequest(t, testSecret, 0, nil) - req.Method = http.MethodDelete - rec, _ := runMiddleware(t, testSecret, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d", rec.Code) - } -} - -func TestHeaderAuth_NonceReplayRejected(t *testing.T) { - cache := newNonceCache(logbookauth.MaxSkew, nonceCacheSize) - req := signedRequest(t, testSecret, 0, nil) - - rec1, _ := runMiddlewareWithCache(t, testSecret, cache, req) - if rec1.Code != http.StatusOK { - t.Fatalf("first request should succeed, got %d", rec1.Code) - } - - // Second call with identical headers (same nonce) must be rejected. - rec2, _ := runMiddlewareWithCache(t, testSecret, cache, signedRequest(t, testSecret, 0, func(c *logbookauth.Claims) { - // Keep everything identical to the first request so the signature - // verifies; only the nonce-cache check can catch this. - *c = logbookauth.Claims{ - Timestamp: c.Timestamp, Method: http.MethodGet, Path: "/api/logbook/buckets", - Nonce: req.Header.Get(logbookauth.HeaderNonce), - UserID: "42", Email: "user@example.com", - FirstName: "Ada", LastName: "Lovelace", - IsAdmin: "true", GroupIDs: "1,2,3", - } - })) - if rec2.Code != http.StatusUnauthorized { - t.Fatalf("nonce replay should be rejected, got %d", rec2.Code) - } -} - -func TestHeaderAuth_GroupIDsCapped(t *testing.T) { - var b []byte - for i := 1; i <= maxGroupIDs+50; i++ { - if i > 1 { - b = append(b, ',') - } - b = append(b, []byte(strconv.Itoa(i))...) - } - req := signedRequest(t, testSecret, 0, func(c *logbookauth.Claims) { - c.GroupIDs = string(b) - }) - _, user := runMiddleware(t, testSecret, req) - if user == nil { - t.Fatal("user should be populated") - } - if len(user.GroupIDs) != maxGroupIDs { - t.Fatalf("want %d group IDs after cap, got %d", maxGroupIDs, len(user.GroupIDs)) - } -} - -// Documents the format failure mode we care about most: if signing and -// verification ever disagree on the canonical string, everything breaks. -func TestCanonical_DeterministicAcrossCalls(t *testing.T) { - claims := logbookauth.Claims{ - Timestamp: 1700000000, - Method: http.MethodGet, - Path: "/api/logbook/buckets", - Nonce: "abc", - UserID: "1", - Email: "a@b.c", - IsAdmin: "false", - } - a := logbookauth.Canonical(claims) - b := logbookauth.Canonical(claims) - if a != b { - t.Fatalf("canonical not deterministic:\n%q\nvs\n%q", a, b) - } - // Regression guard: spot-check version prefix and newline layout. - want := fmt.Sprintf("%s\n1700000000\nGET\n/api/logbook/buckets\nabc\n1\na@b.c\n\n\nfalse\n", logbookauth.SignatureVersion) - if a != want { - t.Fatalf("canonical format changed — update SignatureVersion before changing the format.\ngot: %q\nwant: %q", a, want) - } -} diff --git a/internal/logbookapi/request_body_cap_test.go b/internal/logbookapi/request_body_cap_test.go deleted file mode 100644 index 4641025..0000000 --- a/internal/logbookapi/request_body_cap_test.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build test - -package logbookapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "windshift/internal/restapi" -) - -func TestDecodeJSONOrRespondRejectsOversizedBody(t *testing.T) { - body := `{"value":"` + strings.Repeat("a", int(restapi.DefaultJSONRequestBodyLimit)) + `"}` - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/logbook/example", strings.NewReader(body)) - req.ContentLength = -1 - - var dst map[string]string - if decodeJSONOrRespond(rec, req, &dst) { - t.Fatal("decodeJSONOrRespond accepted an oversized request body") - } - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status = %d, want 413 (body %q)", rec.Code, rec.Body.String()) - } - var response restapi.ErrorResponse - if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { - t.Fatalf("decode error response: %v", err) - } - if response.Code != restapi.ErrCodeRequestTooLarge { - t.Fatalf("code = %q, want %q", response.Code, restapi.ErrCodeRequestTooLarge) - } -} diff --git a/internal/logbookapi/upload_validation_test.go b/internal/logbookapi/upload_validation_test.go deleted file mode 100644 index fcb8b09..0000000 --- a/internal/logbookapi/upload_validation_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package logbookapi - -import ( - "strings" - "testing" -) - -func TestValidateFileExtension_Allowlist(t *testing.T) { - allowed := []string{"report.pdf", "IMG.JPG", "photo.heic", "notes.md", "slides.pptx", "data.csv"} - for _, name := range allowed { - if err := validateFileExtension(name); err != nil { - t.Errorf("%q should be allowed, got error: %v", name, err) - } - } - - denied := []string{ - // active content / scripts - "x.html", "x.htm", "x.xhtml", "x.svg", "x.svgz", "x.mht", "x.mhtml", - "x.js", "x.ts", "x.mjs", - // shell / system - "x.sh", "x.bash", "x.ps1", "x.psm1", "x.bat", "x.cmd", "x.vbs", - "x.hta", "x.chm", "x.reg", "x.wsf", - // macro-bearing Office - "x.docm", "x.xlsm", "x.pptm", - // arbitrary XML - "x.xml", "x.xsl", "x.xslt", - // installers / binaries - "x.exe", "x.msi", "x.dmg", "x.pkg", "x.apk", "x.ipa", "x.deb", "x.rpm", - // shortcut / link - "x.lnk", "x.url", "x.desktop", - // extensionless - "README", ".hidden", - } - for _, name := range denied { - if err := validateFileExtension(name); err == nil { - t.Errorf("%q should be rejected, got nil error", name) - } - } -} - -func TestVerifyFileContent_RejectsMismatch(t *testing.T) { - // HTML bytes claimed to be a PDF — sniffer should catch even though - // validateFileExtension isn't called here. - html := []byte("hi") - if _, err := verifyFileContent(html, "x.pdf"); err == nil { - t.Fatal("expected HTML-vs-PDF mismatch to be rejected") - } -} - -func TestVerifyFileContent_AcceptsZipBased(t *testing.T) { - // Minimal ZIP header; DOCX, XLSX etc. all share this. - zipHeader := []byte{0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x06, 0x00} - padded := append(zipHeader, make([]byte, 512)...) - mt, err := verifyFileContent(padded, "x.docx") - if err != nil { - t.Fatalf("DOCX (zip-based) should be accepted: %v", err) - } - if !strings.Contains(mt, "openxmlformats") && mt != "application/zip" { - t.Fatalf("unexpected mime for docx: %q", mt) - } -} - -func TestVerifyFileContent_OctetStreamRejected(t *testing.T) { - // Random binary bytes that sniff as application/octet-stream but have a - // known extension — must now be rejected (previously silently accepted). - // Note: 0xCA 0xFE 0xBA 0xBE is Mach-O / Java class magic. - b := []byte{0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x34} - if _, err := verifyFileContent(b, "report.pdf"); err == nil { - t.Fatal("octet-stream sniff against a known extension must be rejected") - } -} diff --git a/internal/logbookapi/uuid_validation_test.go b/internal/logbookapi/uuid_validation_test.go deleted file mode 100644 index 126435b..0000000 --- a/internal/logbookapi/uuid_validation_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package logbookapi - -import "testing" - -func TestIsValidUUID(t *testing.T) { - tests := []struct { - name string - id string - want bool - }{ - {name: "canonical", id: "550e8400-e29b-41d4-a716-446655440000", want: true}, - {name: "raw hex", id: "550e8400e29b41d4a716446655440000", want: true}, - {name: "braces", id: "{550e8400-e29b-41d4-a716-446655440000}", want: true}, - {name: "lowercase URN", id: "urn:uuid:550e8400-e29b-41d4-a716-446655440000", want: true}, - {name: "uppercase URN", id: "URN:UUID:550e8400-e29b-41d4-a716-446655440000", want: false}, - {name: "arbitrary wrapper", id: "[550e8400-e29b-41d4-a716-446655440000]", want: false}, - {name: "malformed", id: "not-a-uuid", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isValidUUID(tt.id); got != tt.want { - t.Fatalf("isValidUUID(%q) = %v, want %v", tt.id, got, tt.want) - } - }) - } -} diff --git a/internal/logger/audit_literals_test.go b/internal/logger/audit_literals_test.go new file mode 100644 index 0000000..b137609 --- /dev/null +++ b/internal/logger/audit_literals_test.go @@ -0,0 +1,157 @@ +//go:build test + +package logger + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// auditTaxonomyFile owns the action/resource constants and is the only place +// where the raw strings may appear. +const auditTaxonomyFile = "internal/logger/audit.go" + +// TestNoRawAuditLiterals is the WI-1899 regression guard. Before the taxonomy +// cleanup, ~36 call sites passed action/resource strings inline (for example +// "asset_report_create" / "asset_report" and the agent-binding actions), so a +// rename could not be enforced and the taxonomy drifted between underscore and +// resource.verb naming. This test fails if any audit call site or AuditEvent +// literal passes a raw string for its action or resource, forcing authors to +// add or reuse a logger constant instead. +func TestNoRawAuditLiterals(t *testing.T) { + root := auditModuleRoot(t) + var violations []string + + walkErr := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + if rel == auditTaxonomyFile { + return nil + } + + file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if parseErr != nil { + return parseErr + } + ast.Inspect(file, func(node ast.Node) bool { + switch typed := node.(type) { + case *ast.CallExpr: + if action, resource, ok := auditCallArgs(typed); ok { + violations = appendRawAuditLiterals(violations, rel, action, resource) + } + case *ast.CompositeLit: + if action, resource, ok := auditEventFields(typed); ok { + violations = appendRawAuditLiterals(violations, rel, action, resource) + } + } + return true + }) + return nil + }) + if walkErr != nil { + t.Fatalf("scan audit call sites: %v", walkErr) + } + + if len(violations) > 0 { + t.Errorf("raw audit action/resource literals found — use a constant from internal/logger/audit.go:\n %s", + strings.Join(violations, "\n ")) + } +} + +func appendRawAuditLiterals(violations []string, rel string, action, resource ast.Expr) []string { + if lit, ok := action.(*ast.BasicLit); ok && lit.Kind == token.STRING { + violations = append(violations, rel+": raw action literal "+lit.Value) + } + if lit, ok := resource.(*ast.BasicLit); ok && lit.Kind == token.STRING { + violations = append(violations, rel+": raw resource literal "+lit.Value) + } + return violations +} + +// auditCallArgs returns the action and resource expressions for the audit +// helpers, and whether the call is a recognised audit helper with both +// positions present. +func auditCallArgs(call *ast.CallExpr) (ast.Expr, ast.Expr, bool) { + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + switch fun.Sel.Name { + case "Log", "LogWithDetails", "LogFailure", "LogDenied": + if len(call.Args) >= 4 { + return call.Args[2], call.Args[3], true + } + } + case *ast.Ident: + if fun.Name == "emitServiceAudit" && len(call.Args) >= 4 { + return call.Args[2], call.Args[3], true + } + } + return nil, nil, false +} + +// auditEventFields returns the ActionType and ResourceType expressions of an +// AuditEvent composite literal. +func auditEventFields(lit *ast.CompositeLit) (ast.Expr, ast.Expr, bool) { + if !isAuditEventType(lit.Type) { + return nil, nil, false + } + var action, resource ast.Expr + for _, element := range lit.Elts { + pair, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := pair.Key.(*ast.Ident) + if !ok { + continue + } + switch key.Name { + case "ActionType": + action = pair.Value + case "ResourceType": + resource = pair.Value + } + } + return action, resource, action != nil || resource != nil +} + +func isAuditEventType(expr ast.Expr) bool { + switch typed := expr.(type) { + case *ast.Ident: + return typed.Name == "AuditEvent" + case *ast.SelectorExpr: + return typed.Sel.Name == "AuditEvent" + } + return false +} + +// auditModuleRoot walks up from the test working directory to the module root. +func auditModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("go.mod not found walking up from working directory") + } + dir = parent + } +} diff --git a/internal/logger/audit_test.go b/internal/logger/audit_test.go index d7be5b2..73ce73c 100644 --- a/internal/logger/audit_test.go +++ b/internal/logger/audit_test.go @@ -361,6 +361,92 @@ func TestLogAudit_DetailsRedaction_KeepsBooleanFlags(t *testing.T) { } } +// TestLogAudit_DetailsRedaction_TypedContainers verifies that redaction +// traverses typed containers (structs and typed slices), not only the +// map[string]any/[]any shapes the fast path handles. Regression: a +// []typedSource payload was marshalled unredacted because the sanitizer fell +// through to its default case. +func TestLogAudit_DetailsRedaction_TypedContainers(t *testing.T) { + type typedSource struct { + Name string `json:"name"` + APISecret string `json:"api_secret"` + } + + db := newAuditDetailsTestDB(t) + + if err := LogAudit(db, AuditEvent{ + UserID: 6, + Username: "typed", + ActionType: "test.action", + ResourceType: "test", + Details: map[string]interface{}{ + "sources": []typedSource{{Name: "kept", APISecret: "leak-me"}}, + "primary": typedSource{Name: "kept2", APISecret: "leak-me-too"}, + }, + Success: true, + }); err != nil { + t.Fatalf("LogAudit returned error: %v", err) + } + + var details string + if err := db.QueryRow(`SELECT details FROM audit_logs WHERE username = ?`, "typed").Scan(&details); err != nil { + t.Fatalf("query audit row: %v", err) + } + if strings.Contains(details, "leak-me") { + t.Fatalf("typed container leaked a sensitive value: %s", details) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(details), &parsed); err != nil { + t.Fatalf("details JSON invalid: %v", err) + } + sources, ok := parsed["sources"].([]interface{}) + if !ok || len(sources) != 1 { + t.Fatalf("typed slice not preserved: %#v", parsed["sources"]) + } + first, ok := sources[0].(map[string]interface{}) + if !ok || first["api_secret"] != auditDetailsRedactedValue || first["name"] != "kept" { + t.Fatalf("typed slice element unexpected: %#v", sources[0]) + } + primary, ok := parsed["primary"].(map[string]interface{}) + if !ok || primary["api_secret"] != auditDetailsRedactedValue || primary["name"] != "kept2" { + t.Fatalf("typed struct unexpected: %#v", parsed["primary"]) + } +} + +// TestLogAudit_FailedEventStructuredLog_IncludesErrorAndDetails verifies the +// live log line carries the failure reason and the redacted details, so an +// operator can diagnose a denied or failed mutation without the DB row. +func TestLogAudit_FailedEventStructuredLog_IncludesErrorAndDetails(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + db := newAuditDetailsTestDB(t) + + _ = LogAudit(db, AuditEvent{ + UserID: 8, + Username: "denied", + ActionType: "access.denied", + ResourceType: "route", + Details: map[string]interface{}{"required_permission": "system.admin", "api_secret": "leak"}, + Success: false, + ErrorMessage: "permission_denied", + }) + + logged := buf.String() + if !strings.Contains(logged, "error_message=permission_denied") { + t.Errorf("failed event log missing error_message: %q", logged) + } + if !strings.Contains(logged, "details=") || !strings.Contains(logged, "required_permission") { + t.Errorf("failed event log missing details: %q", logged) + } + if strings.Contains(logged, "leak") { + t.Errorf("structured log leaked a sensitive detail: %q", logged) + } +} + // TestLogAudit_DetailsTruncation_PreservesScalars verifies that an oversized // details payload is shrunk rather than replaced wholesale, so summary // scalars survive for large operations such as bulk cleanup. diff --git a/internal/logger/recorder_test.go b/internal/logger/recorder_test.go new file mode 100644 index 0000000..9b7c2bd --- /dev/null +++ b/internal/logger/recorder_test.go @@ -0,0 +1,118 @@ +//go:build test + +package logger + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "windshift/internal/contextkeys" + "windshift/internal/models" +) + +// TestAuditRecorder_FlushWritesActorAndGatesSuccess covers the WI-1898 +// recorder contract: the middleware-installed recorder flushes once, every +// persisted row carries the authenticated actor, and a success event recorded +// before a later failure is dropped while the explicit failure event survives. +func TestAuditRecorder_FlushWritesActorAndGatesSuccess(t *testing.T) { + db := newAuditDetailsTestDB(t) + auditor := NewAuditor(db) + + request := httptest.NewRequest(http.MethodPatch, "/api/v2/admin/users/42", nil) + request.RemoteAddr = "203.0.113.7:5555" + request.Header.Set("User-Agent", "recorder-test") + request = request.WithContext(context.WithValue(request.Context(), contextkeys.User, &models.User{ + ID: 42, + Username: "alice", + })) + + recorder := NewAuditRecorder() + request = request.WithContext(WithAuditRecorder(request.Context(), recorder)) + + resourceID := 7 + Record(request, "user.update", "user", &resourceID, "alice", map[string]any{"field": "timezone"}) + RecordFailure(request, "user.update", "user", &resourceID, "alice", "conflict", nil) + + // The handler failed, so the success event must not be persisted. + auditor.Flush(recorder, false) + + rows, err := db.Query(`SELECT username, ip_address, user_agent, action_type, success, error_message + FROM audit_logs WHERE resource_type = ? ORDER BY id`, "user") + if err != nil { + t.Fatalf("query audit rows: %v", err) + } + defer func() { _ = rows.Close() }() + + type row struct { + username, ip, userAgent, action, errorMessage string + success bool + } + var got []row + for rows.Next() { + var current row + if err := rows.Scan(¤t.username, ¤t.ip, ¤t.userAgent, ¤t.action, ¤t.success, ¤t.errorMessage); err != nil { + t.Fatalf("scan audit row: %v", err) + } + got = append(got, current) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate audit rows: %v", err) + } + + if len(got) != 1 { + t.Fatalf("expected only the failure event to persist, got %#v", got) + } + failure := got[0] + if failure.username != "alice" || failure.ip != "203.0.113.7" || failure.userAgent != "recorder-test" { + t.Errorf("failure event actor not populated: %#v", failure) + } + if failure.success || failure.errorMessage != "conflict" { + t.Errorf("failure event shape unexpected: %#v", failure) + } + + // Flushing again must not re-write the drained events. + auditor.Flush(recorder, true) + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_logs`).Scan(&count); err != nil { + t.Fatalf("count audit rows: %v", err) + } + if count != 1 { + t.Errorf("second flush wrote duplicate rows: count=%d", count) + } +} + +// TestAuditRecorder_SuccessPersistsOn2xx verifies the positive path: a success +// event flushes when the handler succeeded. +func TestAuditRecorder_SuccessPersistsOn2xx(t *testing.T) { + db := newAuditDetailsTestDB(t) + auditor := NewAuditor(db) + + request := httptest.NewRequest(http.MethodPost, "/api/v2/admin/groups", nil) + request = request.WithContext(context.WithValue(request.Context(), contextkeys.User, &models.User{ + ID: 9, + Username: "bob", + })) + recorder := NewAuditRecorder() + request = request.WithContext(WithAuditRecorder(request.Context(), recorder)) + + Record(request, "group.create", "group", nil, "engineers", nil) + auditor.Flush(recorder, true) + + var username string + if err := db.QueryRow(`SELECT username FROM audit_logs WHERE action_type = ?`, "group.create").Scan(&username); err != nil { + t.Fatalf("query audit row: %v", err) + } + if username != "bob" { + t.Errorf("actor = %q, want bob", username) + } +} + +// TestAuditRecorder_AbsentIsNoOp verifies handlers can call Record without a +// recorder installed (unit tests, non-HTTP callers) without panicking. +func TestAuditRecorder_AbsentIsNoOp(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/v2/admin/groups", nil) + Record(request, "group.create", "group", nil, "engineers", nil) + RecordDenied(request, "access.denied", "route", nil, "/api/v2/admin/groups", "system.admin", nil) +} diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go index ac29bf2..86d98cd 100644 --- a/internal/markdown/render_test.go +++ b/internal/markdown/render_test.go @@ -83,6 +83,27 @@ func TestRender(t *testing.T) { source: "one
two", contains: []string{"one
two"}, }, + { + name: "mention id token renders as display name", + source: "hi @{John Doe#42}!", + contains: []string{"@John Doe"}, + notContain: []string{"#42", "{"}, + }, + { + name: "mention id token inside inline code is literal", + source: "`@{John Doe#42}`", + contains: []string{"@{John Doe#42}"}, + }, + { + name: "mention id token inside fenced code is literal", + source: "```\n@{John Doe#42}\n```", + contains: []string{"@{John Doe#42}"}, + }, + { + name: "mention id token glued to a word is not rewritten", + source: "foo@{John Doe#42}", + contains: []string{"foo@{John Doe#42}"}, + }, } for _, test := range tests { @@ -104,3 +125,23 @@ func TestRender(t *testing.T) { }) } } + +func TestStripMentionIDs(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {"embedded id", "hi @{John Doe#42}!", "hi @John Doe!"}, + {"legacy forms untouched", `hi @jdoe and @"Jane Doe"`, `hi @jdoe and @"Jane Doe"`}, + {"multiple tokens", "@{A B#1} and @{C D#2}", "@A B and @C D"}, + {"missing id untouched", "@{John Doe}", "@{John Doe}"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := StripMentionIDs(test.source); got != test.want { + t.Fatalf("StripMentionIDs(%q) = %q, want %q", test.source, got, test.want) + } + }) + } +} diff --git a/internal/middleware/ratelimit_clientip_test.go b/internal/middleware/ratelimit_clientip_test.go deleted file mode 100644 index a1fbf67..0000000 --- a/internal/middleware/ratelimit_clientip_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package middleware - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -// loginRequest builds a login request whose socket peer is a private (trusted) -// proxy and whose leading X-Forwarded-For entry is attacker-chosen. The trusted -// proxy appends the real client (203.0.113.7) on the right. -func loginRequest(spoofedXFF string) *http.Request { - r := httptest.NewRequest("POST", "/api/auth/login", nil) - r.RemoteAddr = "10.0.0.2:54321" - r.Header.Set("X-Forwarded-For", spoofedXFF+", 203.0.113.7") - return r -} - -func TestRateLimiterGetClientIPStableAcrossRotatingXFF(t *testing.T) { - rl := NewRateLimiter(100, 100, true, nil) - defer rl.Stop() - - for _, spoofed := range []string{"10.9.0.1", "10.9.0.2", "10.9.0.3"} { - if got := rl.getClientIP(loginRequest(spoofed)); got != "203.0.113.7" { - t.Fatalf("getClientIP() with spoofed %q = %q, want real client 203.0.113.7", spoofed, got) - } - } -} - -func TestRateLimiterAllowRequestKeyedOnRealClientDespiteRotatingXFF(t *testing.T) { - rl := NewRateLimiter(1, 1, true, nil) - defer rl.Stop() - - if !rl.AllowRequest(loginRequest("10.9.0.1")) { - t.Fatal("first request for the real client should be allowed") - } - if rl.AllowRequest(loginRequest("10.9.0.2")) { - t.Fatal("rotating X-Forwarded-For bypassed the per-IP rate limit") - } -} diff --git a/internal/models/notification_i18n_test.go b/internal/models/notification_i18n_test.go new file mode 100644 index 0000000..e40c958 --- /dev/null +++ b/internal/models/notification_i18n_test.go @@ -0,0 +1,54 @@ +//go:build test + +package models + +import ( + "reflect" + "testing" +) + +func TestNotificationMetadataI18nRoundTrip(t *testing.T) { + params := map[string]any{"itemKey": "WI-1", "itemTitle": "Fix login"} + metadata := NotificationMetadataJSON("notification.dueReminder.title", "notification.dueReminder.message", params) + if metadata == "" { + t.Fatal("NotificationMetadataJSON returned empty metadata") + } + + titleKey, messageKey, gotParams, ok := ParseNotificationI18n(metadata) + if !ok { + t.Fatalf("ParseNotificationI18n did not recognise %q", metadata) + } + if titleKey != "notification.dueReminder.title" || messageKey != "notification.dueReminder.message" { + t.Fatalf("keys = (%q, %q)", titleKey, messageKey) + } + // JSON round-trips numbers to float64; these params are strings. + if !reflect.DeepEqual(gotParams, params) { + t.Fatalf("params = %#v, want %#v", gotParams, params) + } +} + +func TestNotificationMetadataEmptyIsOmitted(t *testing.T) { + if got := NotificationMetadataJSON("", "", nil); got != "" { + t.Fatalf("empty keys produced metadata %q, want \"\"", got) + } +} + +func TestParseNotificationI18nRejectsNonI18nMetadata(t *testing.T) { + tests := []struct { + name string + metadata string + }{ + {name: "empty", metadata: ""}, + {name: "whitespace", metadata: " "}, + {name: "unrelated json", metadata: `{"foo":"bar"}`}, + {name: "invalid json", metadata: `{`}, + {name: "empty i18n object", metadata: `{"i18n":{}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, _, _, ok := ParseNotificationI18n(tc.metadata); ok { + t.Fatalf("ParseNotificationI18n(%q) reported usable metadata", tc.metadata) + } + }) + } +} diff --git a/internal/models/view_settings_test.go b/internal/models/view_settings_test.go index 2c59051..0d4218d 100644 --- a/internal/models/view_settings_test.go +++ b/internal/models/view_settings_test.go @@ -177,3 +177,40 @@ func TestIsCompletedVisibility(t *testing.T) { t.Fatal("IsCompletedVisibility(fortnight) = true, want false") } } + +func TestViewSettingsCompletedLimit(t *testing.T) { + t.Parallel() + + // The cap rides alongside the preset and is optional: omitted or null + // leaves it unset so the frontend default applies. + var inherit ViewSettings + if err := json.Unmarshal([]byte(`{}`), &inherit); err != nil { + t.Fatalf("Unmarshal({}): %v", err) + } + if inherit.CompletedLimit != nil { + t.Fatalf("CompletedLimit = %v, want nil for inherit", inherit.CompletedLimit) + } + + var reset ViewSettings + if err := json.Unmarshal([]byte(`{"completed_limit": null}`), &reset); err != nil { + t.Fatalf("Unmarshal(null): %v", err) + } + if reset.CompletedLimit != nil { + t.Fatalf("CompletedLimit = %v, want nil for null", reset.CompletedLimit) + } + + var override ViewSettings + if err := json.Unmarshal([]byte(`{"completed_visibility": "limit", "completed_limit": 250}`), &override); err != nil { + t.Fatalf("Unmarshal(value): %v", err) + } + if override.CompletedLimit == nil || *override.CompletedLimit != 250 { + t.Fatalf("CompletedLimit = %v, want 250", override.CompletedLimit) + } + encoded, err := json.Marshal(override) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if string(encoded) != `{"completed_limit":250,"completed_visibility":"limit"}` { + t.Fatalf("Marshal = %s, want completed_limit 250", encoded) + } +} diff --git a/internal/planeimport/converter_test.go b/internal/planeimport/converter_test.go new file mode 100644 index 0000000..2e5aad6 --- /dev/null +++ b/internal/planeimport/converter_test.go @@ -0,0 +1,211 @@ +package planeimport + +import ( + "strings" + "testing" +) + +func TestConvertDescriptionJSON(t *testing.T) { + cases := []struct { + name string + in string + want string + ok bool + }{ + { + name: "empty document", + in: ``, + want: "", + ok: true, + }, + { + name: "plain doc from the description_html column flavour", + in: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, + want: "hello", + ok: true, + }, + { + name: "editor-paragraph-block wrapper", + in: `{"type":"doc","content":[{"type":"editor-paragraph-block","content":[{"type":"paragraph","content":[{"type":"text","text":"hi there"}]}]}]}`, + want: "hi there", + ok: true, + }, + { + name: "heading with marks", + in: `{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}`, + want: "## Title **bold**", + ok: true, + }, + { + name: "code block keeps language", + in: "{\"type\":\"doc\",\"content\":[{\"type\":\"codeBlock\",\"attrs\":{\"language\":\"go\"},\"content\":[{\"type\":\"text\",\"text\":\"fmt.Println(1)\"}]}]}", + want: "```go\nfmt.Println(1)\n```", + ok: true, + }, + { + name: "code block with embedded fence gets a longer fence", + in: "{\"type\":\"doc\",\"content\":[{\"type\":\"codeBlock\",\"content\":[{\"type\":\"text\",\"text\":\"```sh\\nx\\n```\"}]}]}", + want: "````\n```sh\nx\n```\n````", + ok: true, + }, + { + name: "double-encoded document unwraps one level", + in: `"{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"nested\"}]}]}"`, + want: "nested", + ok: true, + }, + { + name: "unordered list", + in: `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"one"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"two"}]}]}]}]}`, + want: "- one\n- two", + ok: true, + }, + { + name: "task list", + in: `{"type":"doc","content":[{"type":"taskList","content":[{"type":"taskItem","attrs":{"checked":true},"content":[{"type":"paragraph","content":[{"type":"text","text":"done thing"}]}]},{"type":"taskItem","content":[{"type":"paragraph","content":[{"type":"text","text":"open thing"}]}]}]}]}`, + want: "- [x] done thing\n- [ ] open thing", + ok: true, + }, + { + name: "blockquote", + in: `{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"quoted"}]}]}]}`, + want: "> quoted", + ok: true, + }, + { + name: "horizontal rule", + in: `{"type":"doc","content":[{"type":"horizontalRule"}]}`, + want: "---", + ok: true, + }, + { + name: "link mark becomes a markdown link", + in: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}],"text":"click"}]}]}`, + want: "[click](https://example.com)", + ok: true, + }, + { + name: "mention becomes an at-label the milkdown chip plugin scans", + in: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"ping "},{"type":"mention","attrs":{"label":"Oskars","id":"u1"},"content":[{"type":"text","text":"Oskars"}]}]}]}`, + want: "ping @ Oskars", + ok: true, + }, + { + name: "unknown node shape signals fallback", + in: `{"type":"doc","content":[{"type":"chartWidget","content":[]}]}`, + want: "", + ok: false, + }, + { + name: "garbage is not a document", + in: `not json`, + want: "", + ok: false, + }, + { + name: "empty paragraph renders nothing but stays ok", + in: `{"type":"doc","content":[{"type":"paragraph"}]}`, + want: "", + ok: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ConvertDescriptionJSON(tc.in) + if ok != tc.ok { + t.Fatalf("ok = %v, want %v (markdown=%q)", ok, tc.ok, got) + } + if tc.ok && got != tc.want { + t.Errorf("markdown =\n%q\nwant\n%q", got, tc.want) + } + }) + } +} + +func TestConvertDescriptionJSON_Table(t *testing.T) { + in := `{"type":"doc","content":[{"type":"table","attrs":{"columnWidths":[33,33],"width":100},"content":[` + + `{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"col a"}]}]},{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"col b"}]}]}]},` + + `{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"1"}]}]},{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"2"}]}]}]}]}]}` + got, ok := ConvertDescriptionJSON(in) + if !ok { + t.Fatalf("table conversion failed: %q", got) + } + want := "| col a | col b |\n|---|---|\n| 1 | 2 |" + if got != want { + t.Errorf("table =\n%q\nwant\n%q", got, want) + } +} + +func TestConvertDescriptionJSON_ListWithNestedBlocks(t *testing.T) { + in := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[` + + `{"type":"paragraph","content":[{"type":"text","text":"outer"}]},` + + `{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}` + got, ok := ConvertDescriptionJSON(in) + if !ok { + t.Fatalf("nested list conversion failed") + } + if !strings.Contains(got, "- outer") || !strings.Contains(got, " - inner") { + t.Errorf("nested list shape wrong:\n%s", got) + } +} + +func TestStoryPointsFromEstimate(t *testing.T) { + five := "5" + empty := "" + bogus := "large-ish" + if v := storyPointsFromEstimate(nil); v != nil { + t.Errorf("nil estimate mapped to %v", *v) + } + if v := storyPointsFromEstimate(&empty); v != nil { + t.Errorf("empty estimate mapped to %v", *v) + } + if v := storyPointsFromEstimate(&bogus); v != nil { + t.Errorf("unparseable estimate invented %v", *v) + } + if v := storyPointsFromEstimate(&five); v == nil || *v != 5 { + t.Errorf("estimate 5 mapped wrong: %v", v) + } +} + +func TestEscapeMarkdownTextProtectsStructure(t *testing.T) { + got := escapeMarkdownText("- not a list\nplain text stays") + if !strings.HasPrefix(got, `\- not a list`) { + t.Errorf("leading list marker not escaped: %q", got) + } + if !strings.HasSuffix(got, "plain text stays") { + t.Errorf("ordinary text was mangled: %q", got) + } +} + +// TestConvertDescriptionJSON_CodeSpanIsLiteral pins the code-mark path: the +// span content is not Markdown-escaped (a backslash would render inside the +// span) and the fence grows past any backtick run so the span cannot be broken. +func TestConvertDescriptionJSON_CodeSpanIsLiteral(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "metacharacters survive unescaped inside a code span", + in: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"*x* _y_","marks":[{"type":"code"}]}]}]}`, + want: "`*x* _y_`", + }, + { + name: "a backtick in the content grows the fence", + in: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"a`b\",\"marks\":[{\"type\":\"code\"}]}]}]}", + want: "``a`b``", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ConvertDescriptionJSON(tc.in) + if !ok { + t.Fatalf("code span conversion failed: %q", got) + } + if got != tc.want { + t.Errorf("code span = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/planeimport/loader_test.go b/internal/planeimport/loader_test.go new file mode 100644 index 0000000..e1a3ba3 --- /dev/null +++ b/internal/planeimport/loader_test.go @@ -0,0 +1,957 @@ +package planeimport + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "windshift/internal/database" +) + +// newTestDB builds a fully initialized Windshift schema on a throwaway SQLite +// file and seeds the minimum a Plane import needs: one workspace and two +// users. Statuses, categories, priorities, and the default workflow come from +// Initialize's own default data — re-seeding them by hand would test a +// fixture, not the product, and the vocabulary pass is a test of exactly what +// Initialize does NOT have: no Backlog, no Blocked, no Cancelled, no Urgent. +func newTestDB(t *testing.T) database.Database { + t.Helper() + db, err := database.NewSQLiteDBWithPoolSizes(filepath.Join(t.TempDir(), "import.db"), 1, 1) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.Initialize(); err != nil { + t.Fatalf("initialize schema: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + seed(t, db, "workspaces", map[string]any{"key": "TEST", "name": "Test", "is_personal": false, "active": true}) + + oskars := seed(t, db, "users", map[string]any{ + "email": "oskars_germovs@inbox.lv", "username": "oskars_germovs", + "first_name": "Oskars", "last_name": "Germovs", "is_active": true, + }) + gnarl := seed(t, db, "users", map[string]any{ + "email": "gnarl@solum-designum.eu", "username": "gnarl", + "first_name": "Gnarl", "last_name": "", "is_active": true, + "is_agent": true, + }) + look := func(table, name string) int64 { + t.Helper() + var id int64 + if err := db.QueryRow(fmt.Sprintf(`SELECT id FROM %s WHERE name = ? LIMIT 1`, table), name).Scan(&id); err != nil { + t.Fatalf("default %s %q missing after Initialize: %v", table, name, err) + } + return id + } + seededIDs = struct { + oskars, gnarl, open, inProg, done int64 + }{oskars, gnarl, look("statuses", "Open"), look("statuses", "In Progress"), look("statuses", "Done")} + return db +} + +var seededIDs struct { + oskars, gnarl, open, inProg, done int64 +} + +// seed inserts one row into table with the given column values, defaulting any +// NOT NULL column the caller omitted, and returns the new row id. +func seed(t *testing.T, db database.Database, table string, values map[string]any) int64 { + t.Helper() + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + t.Fatalf("pragma %s: %v", table, err) + } + type col struct { + name string + typ string + notNull bool + hasDefault bool + pk bool + } + var cols []col + for rows.Next() { + var cid, notNull, pk int + var name, colType string + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil { + _ = rows.Close() + t.Fatalf("scan pragma %s: %v", table, err) + } + cols = append(cols, col{name: name, typ: colType, notNull: notNull == 1, hasDefault: dflt.Valid, pk: pk == 1}) + } + _ = rows.Close() + + var names []string + var holders []string + var args []any + provided := map[string]bool{} + for k := range values { + provided[strings.ToLower(k)] = true + } + for _, c := range cols { + key := c.name + if v, ok := values[key]; ok { + names = append(names, quoteIdent(c.name)) + holders = append(holders, "?") + args = append(args, v) + continue + } + if provided[c.name] || c.pk || !c.notNull || c.hasDefault { + continue + } + // NOT NULL without default and not provided: give it the honest + // zero for its type so the seed cannot mask a real schema change. + names = append(names, quoteIdent(c.name)) + holders = append(holders, "?") + args = append(args, zeroFor(c.typ)) + } + var id int64 + q := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) RETURNING id", + table, strings.Join(names, ", "), strings.Join(holders, ", ")) + if err := db.QueryRow(q, args...).Scan(&id); err != nil { + t.Fatalf("seed %s: %v", table, err) + } + return id +} + +func quoteIdent(s string) string { return `"` + s + `"` } + +func zeroFor(typ string) any { + u := strings.ToUpper(typ) + switch { + case strings.Contains(u, "INT"): + return 0 + case strings.Contains(u, "REAL"), strings.Contains(u, "FLOA"), strings.Contains(u, "DOUB"): + return 0.0 + default: + return "" + } +} + +func writeContract(t *testing.T, meta string, records ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "contract.jsonl") + body := meta + "\n" + strings.Join(records, "\n") + "\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write contract: %v", err) + } + return path +} + +func writeUsers(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "users.json") + users := []UserRow{ + {ID: "u-oskars", Email: "hakerx1@gmail.com", FirstName: "Oskars", LastName: "Germovs"}, + {ID: "u-gnarl", Email: "gnarl@solum-designum.eu", FirstName: "Gnarl"}, + } + data, _ := json.Marshal(users) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write users: %v", err) + } + return path +} + +// containsExact reports whether name appears as its own entry. The report is +// operator-facing: a line that buries a value inside another list, or +// prefixes it, is a line an operator has to parse by hand. +func containsExact(list []string, name string) bool { + for _, v := range list { + if v == name { + return true + } + } + return false +} + +// statusID looks a status up BY NAME — after the fidelity pass the source's +// state names are the lookup keys, not any builtin mapping. +func statusID(t *testing.T, db database.Database, name string) int64 { + t.Helper() + var id int64 + if err := db.QueryRow(`SELECT id FROM statuses WHERE name = ? LIMIT 1`, name).Scan(&id); err != nil { + t.Fatalf("status %q missing after import: %v", name, err) + } + return id +} + +// statusCategoryBuiltin reports which status-category builtin_key a status +// lands under: the placement the source's GROUP field chose. +func statusCategoryBuiltin(t *testing.T, db database.Database, name string) (string, bool) { + t.Helper() + var builtin string + var completed bool + err := db.QueryRow(` + SELECT c.builtin_key, c.is_completed FROM statuses s + JOIN status_categories c ON c.id = s.category_id + WHERE s.name = ? LIMIT 1`, name).Scan(&builtin, &completed) + if err != nil { + t.Fatalf("category of status %q: %v", name, err) + } + return builtin, completed +} + +// fidelityContract is the v2 shape the dumper emits: state/label vocabulary +// records, per-issue state_group + label_ids. The state set mirrors the live +// VECTIS project (including Cancelled and Triage with zero open issues, and +// Blocked whose group is started — the measured fact a name-based map gets +// wrong); labels mirror the real catalog's used rows. +func fidelityContract() (string, []string) { + meta := `{"type":"meta","meta":{"schema_version":2,"source":"plane","workspace_slug":"solum-designum","project_id":"p-1","project_key":"TEST","dumped_at":"2026-10-26T00:00:00Z"}}` + return meta, []string{ + `{"type":"state","state":{"id":"s-1","name":"Todo","group":"unstarted","color":"10B981"}}`, + `{"type":"state","state":{"id":"s-2","name":"In Progress","group":"started","color":"#3B82F6"}}`, + `{"type":"state","state":{"id":"s-3","name":"Blocked","group":"started","color":"#F97316"}}`, + `{"type":"state","state":{"id":"s-4","name":"Cancelled","group":"cancelled","color":"#6B7280"}}`, + `{"type":"state","state":{"id":"s-5","name":"Done","group":"completed","color":"#22C55E"}}`, + `{"type":"state","state":{"id":"s-6","name":"Triage","group":"triage","color":"#F59E0B"}}`, + `{"type":"label","label":{"id":"l-1","name":"ready-for-agent","color":"#10B981"}}`, + `{"type":"label","label":{"id":"l-2","name":"blocker","color":"b60205"}}`, + // #1 — Done by Gnarl, 2024, carries a label. + `{"type":"issue","issue":{"id":"i-1","sequence_id":1,"name":"first","state_name":"Done","state_group":"completed","label_ids":["l-1"],"priority":"none","created_at":"2024-01-02T03:04:05Z","updated_at":"2024-01-05T00:00:00Z","created_by_id":"u-gnarl","updated_by_id":"u-gnarl","description_json":"{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"body one\"}]}]}"}}`, + // #2 — In Progress by Oskars (Plane email needs the alias), story points. + `{"type":"issue","issue":{"id":"i-2","sequence_id":2,"name":"second","state_name":"In Progress","state_group":"started","priority":"medium","estimate_point":"8","created_at":"2024-02-02T00:00:00Z","updated_at":"2024-02-03T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars","target_date":"2024-03-01T00:00:00Z"}}`, + // #3 — soft-deleted: number must stay a gap. + `{"type":"issue","issue":{"id":"i-3","sequence_id":3,"name":"gone","state_name":"Todo","state_group":"unstarted","priority":"none","created_at":"2024-02-04T00:00:00Z","updated_at":"2024-02-04T00:00:00Z","deleted_at":"2024-02-05T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + // #4 — child of i-2, Todo, labelled blocker. + `{"type":"issue","issue":{"id":"i-4","sequence_id":4,"name":"child","state_name":"Todo","state_group":"unstarted","label_ids":["l-2"],"priority":"low","parent_id":"i-2","parent_sequence_id":2,"created_at":"2024-02-06T00:00:00Z","updated_at":"2024-02-06T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + // #5 — Cancelled: its own status under its own category, not folded into Done. + `{"type":"issue","issue":{"id":"i-5","sequence_id":5,"name":"cancelled","state_name":"Cancelled","state_group":"cancelled","priority":"none","created_at":"2024-02-07T00:00:00Z","updated_at":"2024-02-07T00:00:00Z","created_by_id":"u-gnarl","updated_by_id":"u-gnarl"}}`, + // #6 — Blocked (group started) with a comment and a label. + `{"type":"issue","issue":{"id":"i-6","sequence_id":6,"name":"commented","state_name":"Blocked","state_group":"started","label_ids":["l-2"],"priority":"none","created_at":"2024-02-08T00:00:00Z","updated_at":"2024-02-08T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + `{"type":"comment","comment":{"id":"c-1","issue_id":"i-6","comment_json":"","comment_html":"","text":"keep me","created_at":"2024-02-09T10:00:00Z","updated_at":"2024-02-09T10:00:00Z","created_by_id":"u-oskars","access":"INTERNAL"}}`, + // #7 — child of #6 but the PARENT has a higher sequence id: the + // measured Plane shape where in-pass linking must defer. + `{"type":"issue","issue":{"id":"i-7","sequence_id":7,"name":"late-parent-child","state_name":"Todo","state_group":"unstarted","priority":"none","parent_id":"i-8","parent_sequence_id":8,"created_at":"2024-02-10T00:00:00Z","updated_at":"2024-02-10T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + `{"type":"issue","issue":{"id":"i-8","sequence_id":8,"name":"late-parent","state_name":"Todo","state_group":"unstarted","priority":"none","created_at":"2024-02-11T00:00:00Z","updated_at":"2024-02-11T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + // #9 — a state GROUP and a priority neither map covers. The status is + // still created (1:1), but on a to_do GUESS that must be reported by + // name; the priority lands on NULL and is reported the same way. + `{"type":"issue","issue":{"id":"i-9","sequence_id":9,"name":"unmappable","state_name":"Investigating","state_group":"investigating","priority":"squeek","created_at":"2024-02-12T00:00:00Z","updated_at":"2024-02-12T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + // #10 — urgent: the rung above Critical on Plane's scale, which + // Windshift does not seed. Fidelity creates the row; dropping to NULL + // or folding into Critical would both misstate the source. + `{"type":"issue","issue":{"id":"i-10","sequence_id":10,"name":"urgent-ticket","state_name":"Todo","state_group":"unstarted","priority":"urgent","created_at":"2024-02-13T00:00:00Z","updated_at":"2024-02-13T00:00:00Z","created_by_id":"u-oskars","updated_by_id":"u-oskars"}}`, + } +} + +// TestLoaderEndToEnd walks the fidelity contract on a real schema: the source +// state vocabulary lands 1:1 (statuses created under the category the GROUP +// implies, Cancelled given its own completed category, Triage present with no +// issues on it), urgent gets its priority row, labels are created and linked, +// new statuses are wired into the default workflow, explicit numbers keep +// their gaps, authors resolve through aliases, Plane timestamps survive, and +// reruns CONVERGE untouched rows while deferring to edited ones. +func TestLoaderEndToEnd(t *testing.T) { + db := newTestDB(t) + meta, records := fidelityContract() + contractPath := writeContract(t, meta, records...) + usersPath := writeUsers(t) + + newLoader := func(dry bool) *Loader { + return NewLoader(db, Options{ + WorkspaceKey: "TEST", ContractPath: contractPath, UsersPath: usersPath, + Aliases: map[string]string{"hakerx1@gmail.com": "oskars_germovs@inbox.lv"}, + DryRun: dry, BatchSize: 3, + }) + } + + l := newLoader(false) + if err := l.LoadContract(); err != nil { + t.Fatalf("load contract: %v", err) + } + if err := l.Resolve(); err != nil { + t.Fatalf("resolve: %v", err) + } + report, err := l.Run(context.Background()) + if err != nil { + t.Fatalf("run: %v", err) + } + + if report.IssuesImported != 9 { + t.Errorf("imported = %d, want 9", report.IssuesImported) + } + if report.ParentsLinkedLate != 1 { + t.Errorf("late-linked parents = %d, want 1 (child #7 has parent #8)", report.ParentsLinkedLate) + } + if report.IssuesSoftDeleted != 1 { + t.Errorf("soft-deleted skipped = %d, want 1", report.IssuesSoftDeleted) + } + if report.CommentsImported != 1 { + t.Errorf("comments imported = %d, want 1", report.CommentsImported) + } + if report.MaxNumber != 10 { + t.Errorf("max number = %d, want 10", report.MaxNumber) + } + if !containsExact(report.UnmappedStates, "Investigating") { + t.Errorf("unmapped states = %v, want the bare name \"Investigating\"", report.UnmappedStates) + } + if containsExact(report.UnmappedStates, "Blocked") { + t.Errorf("unmapped states = %v, must NOT list Blocked: its group (started) is known and decides placement", report.UnmappedStates) + } + if !containsExact(report.UnmappedPriorities, "squeek") { + t.Errorf("unmapped priorities = %v, want the bare name \"squeek\"", report.UnmappedPriorities) + } + if fmt.Sprint(report.CreatedStatuses) != "[Blocked Cancelled Investigating Todo Triage]" { + t.Errorf("created statuses = %v, want the five names Windshift lacked (Done/In Progress are ADOPTED by name, not duplicated)", report.CreatedStatuses) + } + if fmt.Sprint(report.CreatedCategories) != "[Cancelled]" { + t.Errorf("created categories = %v, want [Cancelled] — the one group Windshift does not seed", report.CreatedCategories) + } + if fmt.Sprint(report.CreatedPriorities) != "[urgent]" { + t.Errorf("created priorities = %v, want [urgent]", report.CreatedPriorities) + } + if len(report.CreatedLabels) != 2 || !containsExact(report.CreatedLabels, "ready-for-agent") || !containsExact(report.CreatedLabels, "blocker") { + t.Errorf("created labels = %v, want ready-for-agent and blocker", report.CreatedLabels) + } + if report.TransitionsAdded != 35 { + t.Errorf("transitions added = %d, want 35 (one from-all entry + six exits per created status)", report.TransitionsAdded) + } + if report.LabelLinksAdded != 3 { + t.Errorf("label links added = %d, want 3", report.LabelLinksAdded) + } + if report.IssuesConverged != 0 || report.IssuesLeftEdited != 0 { + t.Errorf("first-run convergence counters = %d/%d, want 0/0 (nothing was pre-existing)", report.IssuesConverged, report.IssuesLeftEdited) + } + if len(report.NumberGaps) != 1 || report.NumberGaps[0] != 3 { + t.Errorf("gaps = %v, want [3]", report.NumberGaps) + } + if len(report.UnmappedUsers) != 0 { + t.Errorf("unmapped users = %v, want none (alias must resolve hakerx1)", report.UnmappedUsers) + } + if len(report.Collisions) != 0 { + t.Errorf("collisions = %v, want none", report.Collisions) + } + // Vocabulary landed in the database, not just in the report. + var statusCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM statuses`).Scan(&statusCount); err != nil { + t.Fatalf("count statuses: %v", err) + } + if statusCount != 8 { + t.Errorf("statuses = %d rows, want 8 (3 seeded + 5 created; Done/In Progress adopted, never duplicated)", statusCount) + } + // Adopted statuses are the seeded rows themselves. + type itemProbe struct { + number int + status int64 + creator sql.NullInt64 + prio sql.NullInt64 + created time.Time + updated time.Time + story sql.NullFloat64 + parent sql.NullInt64 + due sql.NullString + } + rows, err := db.Query(`SELECT workspace_item_number, status_id, creator_id, priority_id, created_at, updated_at, story_points, parent_id, COALESCE(due_date,'') FROM items WHERE workspace_id = 1 ORDER BY workspace_item_number`) + if err != nil { + t.Fatalf("read items: %v", err) + } + byNumber := map[int]itemProbe{} + for rows.Next() { + var r itemProbe + if err := rows.Scan(&r.number, &r.status, &r.creator, &r.prio, &r.created, &r.updated, &r.story, &r.parent, &r.due); err != nil { + _ = rows.Close() + t.Fatalf("scan item: %v", err) + } + byNumber[r.number] = r + } + _ = rows.Close() + var nums []int + for n := range byNumber { + nums = append(nums, n) + } + sort.Ints(nums) + if fmt.Sprint(nums) != "[1 2 4 5 6 7 8 9 10]" { + t.Fatalf("item numbers = %v, want [1 2 4 5 6 7 8 9 10]", nums) + } + + // Next create must continue above the maximum. + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + next, err := mustNumber(t, tx) + if err != nil { + t.Fatalf("next number: %v", err) + } + _ = tx.Rollback() + if next != 11 { + t.Errorf("next allocated number = %d, want 11", next) + } + + if id := seededIDs.gnarl; byNumber[1].creator.Int64 != id || !byNumber[1].creator.Valid { + t.Errorf("#1 creator = %v, want gnarl %d", byNumber[1].creator, id) + } + if want, _ := time.Parse(time.RFC3339, "2024-01-02T03:04:05Z"); !byNumber[1].created.Equal(want) { + t.Errorf("#1 created_at = %v, want %v (Plane chronology must survive)", byNumber[1].created, want) + } + if want, _ := time.Parse(time.RFC3339, "2024-01-05T00:00:00Z"); !byNumber[1].updated.Equal(want) { + t.Errorf("#1 updated_at = %v, want the contract value %v", byNumber[1].updated, want) + } + if byNumber[1].status != seededIDs.done { + t.Errorf("#1 status = %d, want the SEEDED Done row %d (adoption, not a second Done)", byNumber[1].status, seededIDs.done) + } + if byNumber[2].status != seededIDs.inProg { + t.Errorf("#2 status = %d, want the seeded In Progress row %d", byNumber[2].status, seededIDs.inProg) + } + // Cancelled: its own status, under a category Plane's group names and + // Windshift's burndown must treat as completed. + cancelID := statusID(t, db, "Cancelled") + if byNumber[5].status != cancelID { + t.Errorf("#5 status = %d, want Cancelled %d — abandoned work is not Done", byNumber[5].status, cancelID) + } + if builtin, completed := statusCategoryBuiltin(t, db, "Cancelled"); builtin != "cancelled" || !completed { + t.Errorf("Cancelled category = (%s, completed=%v), want (cancelled, true)", builtin, completed) + } + // Blocked: the GROUP (started) decides — in_progress — even though every + // name-based map would file it under to_do. This is the measured VECTIS + // fact the collapse used to get wrong. + blockedID := statusID(t, db, "Blocked") + if byNumber[6].status != blockedID { + t.Errorf("#6 status = %d, want Blocked %d", byNumber[6].status, blockedID) + } + if builtin, _ := statusCategoryBuiltin(t, db, "Blocked"); builtin != "in_progress" { + t.Errorf("Blocked category = %s, want in_progress (group started decides, not the name)", builtin) + } + // Todo (unstarted) and Triage (triage) both land under to_do; Triage has + // no issues on it and must still exist — the vocabulary is the project's, + // not just the states with traffic. + if builtin, _ := statusCategoryBuiltin(t, db, "Todo"); builtin != "to_do" { + t.Errorf("Todo category = %s, want to_do", builtin) + } + _ = statusID(t, db, "Triage") + if builtin, _ := statusCategoryBuiltin(t, db, "Investigating"); builtin != "to_do" { + t.Errorf("Investigating category = %s, want the reported to_do fallback", builtin) + } + // Urgent: created ABOVE Critical (sort 0 < 1), and #10 actually carries it. + var urgentID int64 + var urgentSort int + if err := db.QueryRow(`SELECT id, sort_order FROM priorities WHERE name = 'Urgent' LIMIT 1`).Scan(&urgentID, &urgentSort); err != nil { + t.Fatalf("Urgent priority missing: %v", err) + } + if urgentSort != 0 { + t.Errorf("Urgent sort_order = %d, want 0 (above the seeded Critical at 1)", urgentSort) + } + if byNumber[10].prio.Int64 != urgentID || !byNumber[10].prio.Valid { + t.Errorf("#10 priority = %v, want Urgent %d (urgent must not fall to NULL or Critical)", byNumber[10].prio, urgentID) + } + // Labels: created with the source's colours normalised to the #-form the + // destination columns carry, and linked to exactly the items that had them. + type labelProbe struct { + name, color string + } + var labels []labelProbe + rows, err = db.Query(`SELECT name, color FROM labels ORDER BY name`) + if err != nil { + t.Fatalf("read labels: %v", err) + } + for rows.Next() { + var r labelProbe + if err := rows.Scan(&r.name, &r.color); err != nil { + _ = rows.Close() + t.Fatalf("scan label: %v", err) + } + labels = append(labels, r) + } + _ = rows.Close() + if fmt.Sprint(labels) != "[{blocker #b60205} {ready-for-agent #10B981}]" { + t.Errorf("labels = %v, want exactly blocker(#b60205) + ready-for-agent(#10B981)", labels) + } + var readyColor, blockerColor string + if err := db.QueryRow(`SELECT color FROM labels WHERE name = 'ready-for-agent'`).Scan(&readyColor); err != nil { + t.Fatalf("ready color: %v", err) + } + if readyColor != "#10B981" { + t.Errorf("ready-for-agent color = %q, want #10B981", readyColor) + } + if err := db.QueryRow(`SELECT color FROM labels WHERE LOWER(name) = 'blocker'`).Scan(&blockerColor); err != nil { + t.Fatalf("blocker color: %v", err) + } + if blockerColor != "#b60205" { + t.Errorf("blocker color = %q, want #b60205 (bare-hex source colour normalised, not invented)", blockerColor) + } + type linkProbe struct { + n int + name string + } + var links []linkProbe + rows, err = db.Query(` + SELECT i.workspace_item_number, l.name + FROM items i JOIN item_labels il ON il.item_id = i.id + JOIN labels l ON l.id = il.label_id + WHERE i.workspace_id = 1 ORDER BY 1, 2`) + if err != nil { + t.Fatalf("read links: %v", err) + } + for rows.Next() { + var r linkProbe + if err := rows.Scan(&r.n, &r.name); err != nil { + _ = rows.Close() + t.Fatalf("scan link: %v", err) + } + links = append(links, r) + } + _ = rows.Close() + if fmt.Sprint(links) != "[{1 ready-for-agent} {4 blocker} {6 blocker}]" { + t.Errorf("label links = %v, want [{1 ready-for-agent} {4 blocker} {6 blocker}]", links) + } + + // New statuses are wired into the workflow the workspace actually + // resolves: each gets a from-all entry (so anything may move ONTO it) and + // explicit exits (so its items may move OFF it). Without this they would + // be islands — items land there and nothing may move them again. + var entries int + if err := db.QueryRow(`SELECT COUNT(*) FROM workflow_transitions WHERE from_all_statuses = true`).Scan(&entries); err != nil { + t.Fatalf("count from-all transitions: %v", err) + } + if entries != 5 { + t.Errorf("from-all entries = %d, want 5 (one per created status)", entries) + } + var islands int + if err := db.QueryRow(` + SELECT COUNT(*) FROM statuses s + WHERE s.name IN ('Todo','Blocked','Cancelled','Triage','Investigating') + AND NOT EXISTS (SELECT 1 FROM workflow_transitions wt WHERE wt.workflow_id = 1 AND wt.to_status_id = s.id AND wt.from_all_statuses = true) + AND NOT EXISTS (SELECT 1 FROM workflow_transitions wt WHERE wt.workflow_id = 1 AND wt.from_status_id = s.id)`).Scan(&islands); err != nil { + t.Fatalf("check islands: %v", err) + } + if islands != 0 { + t.Errorf("island statuses = %d, want 0", islands) + } + + // Per-row fidelity that predates this rework and must survive it. + if id := seededIDs.oskars; byNumber[2].creator.Int64 != id { + t.Errorf("#2 creator = %v, want oskars %d (alias resolution failed)", byNumber[2].creator, id) + } + if v := byNumber[2].story; !v.Valid || v.Float64 != 8 { + t.Errorf("#2 story points = %v, want 8", v) + } + if !byNumber[2].due.Valid || !strings.HasPrefix(byNumber[2].due.String, "2024-03-01") { + t.Errorf("#2 due_date = %v, want 2024-03-01 (target_date must map to due_date)", byNumber[2].due) + } + var childParent sql.NullInt64 + var parentID int64 + if err := db.QueryRow(`SELECT parent_id FROM items WHERE workspace_id = 1 AND workspace_item_number = 4`).Scan(&childParent); err != nil { + t.Fatalf("read child parent: %v", err) + } + if err := db.QueryRow(`SELECT id FROM items WHERE workspace_id = 1 AND workspace_item_number = 2`).Scan(&parentID); err != nil { + t.Fatalf("read parent id: %v", err) + } + if !childParent.Valid || childParent.Int64 != parentID { + t.Errorf("#4 parent = %v, want %d (parent link lost)", childParent, parentID) + } + var lateParent sql.NullInt64 + var lateParentID int64 + if err := db.QueryRow(`SELECT parent_id FROM items WHERE workspace_id = 1 AND workspace_item_number = 7`).Scan(&lateParent); err != nil { + t.Fatalf("read late child parent: %v", err) + } + if err := db.QueryRow(`SELECT id FROM items WHERE workspace_id = 1 AND workspace_item_number = 8`).Scan(&lateParentID); err != nil { + t.Fatalf("read late parent id: %v", err) + } + if !lateParent.Valid || lateParent.Int64 != lateParentID { + t.Errorf("#7 parent = %v, want %d (second-pass parent link failed)", lateParent, lateParentID) + } + + // Comment fidelity: author and timestamp from the contract. + var cAuthor sql.NullInt64 + var cCreated time.Time + var cContent string + if err := db.QueryRow(`SELECT author_id, created_at, content FROM comments WHERE item_id = (SELECT id FROM items WHERE workspace_id=1 AND workspace_item_number=6)`).Scan(&cAuthor, &cCreated, &cContent); err != nil { + t.Fatalf("read comment: %v", err) + } + if cAuthor.Int64 != seededIDs.oskars { + t.Errorf("comment author = %v, want oskars %d", cAuthor, seededIDs.oskars) + } + if want, _ := time.Parse(time.RFC3339, "2024-02-09T10:00:00Z"); !cCreated.Equal(want) { + t.Errorf("comment created_at = %v, want %v", cCreated, want) + } + if cContent != "keep me" { + t.Errorf("comment content = %q, want %q", cContent, "keep me") + } + + // Provenance: mapping metadata keeps the source state AND the resolved + // label names — the row's own history, whatever later runs do to it. + var metaJSON string + if err := db.QueryRow(`SELECT metadata_json FROM plane_import_mappings WHERE workspace_id=1 AND entity_type='issue' AND external_id='i-5'`).Scan(&metaJSON); err != nil { + t.Fatalf("read mapping: %v", err) + } + if !strings.Contains(metaJSON, "Cancelled") { + t.Errorf("mapping metadata = %q, must retain the Plane state name", metaJSON) + } + if err := db.QueryRow(`SELECT metadata_json FROM plane_import_mappings WHERE workspace_id=1 AND entity_type='issue' AND external_id='i-1'`).Scan(&metaJSON); err != nil { + t.Fatalf("read mapping i-1: %v", err) + } + if !strings.Contains(metaJSON, "ready-for-agent") { + t.Errorf("mapping metadata i-1 = %q, must carry the resolved label names", metaJSON) + } + + // Created facts exist for each imported item with the import provenance, + // and the imported comment records its own comment-created fact. + var factCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE workspace_id = 1 AND event_type = 'item.created'`).Scan(&factCount); err != nil { + t.Fatalf("count events: %v", err) + } + if factCount != 9 { + t.Errorf("event facts = %d, want 9 (one created fact per imported item)", factCount) + } + var commentFactCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE workspace_id = 1 AND event_type = 'item.comment_created'`).Scan(&commentFactCount); err != nil { + t.Fatalf("count comment events: %v", err) + } + if commentFactCount != 1 { + t.Errorf("comment facts = %d, want 1 (the imported comment records its canonical fact)", commentFactCount) + } + // Rerun on unchanged data: everything skipped, everything already correct, + // so convergence has nothing to do — and it invents nothing. + l2 := newLoader(false) + if err := l2.LoadContract(); err != nil { + t.Fatalf("rerun load: %v", err) + } + if err := l2.Resolve(); err != nil { + t.Fatalf("rerun resolve: %v", err) + } + report2, err := l2.Run(context.Background()) + if err != nil { + t.Fatalf("rerun: %v", err) + } + if report2.IssuesImported != 0 || report2.IssuesExisting != 9 || report2.CommentsImported != 0 || report2.CommentsExisting != 1 { + t.Errorf("rerun report = %+v, want all-skipped", report2) + } + if report2.IssuesConverged != 0 || report2.IssuesLeftEdited != 0 || report2.LabelLinksAdded != 0 || report2.TransitionsAdded != 0 { + t.Errorf("rerun repair counters = %+v, want 0 converged / 0 deferred / 0 links / 0 transitions on unchanged data", report2) + } + if len(report2.CreatedStatuses)+len(report2.CreatedCategories)+len(report2.CreatedPriorities)+len(report2.CreatedLabels) != 0 { + t.Errorf("rerun created vocabulary = %v/%v/%v/%v, want nothing recreated", report2.CreatedStatuses, report2.CreatedCategories, report2.CreatedPriorities, report2.CreatedLabels) + } + + // Convergence, proved two ways. (A) Simulate the v1 collapse AFTER the + // fact: knock #1 off Done onto the Open row without moving updated_at — + // exactly the state an old import leaves behind. (B) Edit #2 through a + // write that DOES move updated_at — the operator's later decision. The + // rerun must repair A and defer to B. + if _, err := db.ExecWrite(`UPDATE items SET status_id = ? WHERE workspace_id = 1 AND workspace_item_number = 1`, seededIDs.open); err != nil { + t.Fatalf("collapse #1: %v", err) + } + postEdit := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + if _, err := db.ExecWrite(`UPDATE items SET status_id = ?, updated_at = ? WHERE workspace_id = 1 AND workspace_item_number = 2`, seededIDs.open, postEdit); err != nil { + t.Fatalf("post-import edit #2: %v", err) + } + l2b := newLoader(false) + if err := l2b.LoadContract(); err != nil { + t.Fatalf("converge load: %v", err) + } + if err := l2b.Resolve(); err != nil { + t.Fatalf("converge resolve: %v", err) + } + report2b, err := l2b.Run(context.Background()) + if err != nil { + t.Fatalf("converge run: %v", err) + } + if report2b.IssuesConverged != 1 { + t.Errorf("converged = %d, want 1 (untouched #1 repaired onto Done)", report2b.IssuesConverged) + } + if report2b.IssuesLeftEdited != 1 { + t.Errorf("left alone (edited) = %d, want 1 (post-import edit on #2 outranks the import)", report2b.IssuesLeftEdited) + } + var afterStatus int64 + var afterUpdated time.Time + if err := db.QueryRow(`SELECT status_id, updated_at FROM items WHERE workspace_id = 1 AND workspace_item_number = 1`).Scan(&afterStatus, &afterUpdated); err != nil { + t.Fatalf("read #1 after converge: %v", err) + } + if afterStatus != seededIDs.done { + t.Errorf("#1 status after converge = %d, want Done %d", afterStatus, seededIDs.done) + } + if want, _ := time.Parse(time.RFC3339, "2024-01-05T00:00:00Z"); !afterUpdated.Equal(want) { + t.Errorf("#1 updated_at after converge = %v, want the untouched contract value %v (convergence must not stamp time)", afterUpdated, want) + } + if err := db.QueryRow(`SELECT status_id FROM items WHERE workspace_id = 1 AND workspace_item_number = 2`).Scan(&afterStatus); err != nil { + t.Fatalf("read #2 after converge: %v", err) + } + if afterStatus != seededIDs.open { + t.Errorf("#2 status after converge = %d, want the operator's Open %d left alone", afterStatus, seededIDs.open) + } + + // Partial replay: drop #4's row and mapping (a batch lost mid-write), then + // rerun: only #4 comes back, at number 4, with its label re-linked. + if _, err := db.ExecWrite(`DELETE FROM plane_import_mappings WHERE workspace_id = 1 AND entity_type='issue' AND external_id='i-4'`); err != nil { + t.Fatalf("drop mapping: %v", err) + } + if _, err := db.ExecWrite(`DELETE FROM items WHERE workspace_id = 1 AND workspace_item_number = 4`); err != nil { + t.Fatalf("clear number: %v", err) + } + l3 := newLoader(false) + if err := l3.LoadContract(); err != nil { + t.Fatalf("replay load: %v", err) + } + if err := l3.Resolve(); err != nil { + t.Fatalf("replay resolve: %v", err) + } + report3, err := l3.Run(context.Background()) + if err != nil { + t.Fatalf("replay: %v", err) + } + if report3.IssuesImported != 1 || report3.IssuesExisting != 8 { + t.Errorf("replay report = %+v, want 1 imported 8 existing", report3) + } + if report3.LabelLinksAdded != 1 { + t.Errorf("replay label links = %d, want 1 (the re-imported #4 carries blocker)", report3.LabelLinksAdded) + } + if report3.IssuesLeftEdited != 1 { + t.Errorf("replay deferred = %d, want 1 (#2 is still operator-edited)", report3.IssuesLeftEdited) + } + var replayNumber int + if err := db.QueryRow(`SELECT i.workspace_item_number FROM plane_import_mappings m JOIN items i ON i.id = m.windshift_id WHERE m.workspace_id=1 AND m.external_id='i-4'`).Scan(&replayNumber); err != nil { + t.Fatalf("read replay number: %v", err) + } + if replayNumber != 4 { + t.Errorf("replayed #4 landed on number %d, want 4", replayNumber) + } + + // Collision: a number claimed by a row the importer does not own aborts. + if _, err := db.ExecWrite(`DELETE FROM plane_import_mappings WHERE workspace_id = 1 AND entity_type='issue' AND external_id='i-4'`); err != nil { + t.Fatalf("drop mapping for collision test: %v", err) + } + l4 := newLoader(false) + if err := l4.LoadContract(); err != nil { + t.Fatalf("collision load: %v", err) + } + if err := l4.Resolve(); err != nil { + t.Fatalf("collision resolve: %v", err) + } + if _, err := l4.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "collision") { + t.Errorf("collision run error = %v, want a collision refusal", err) + } +} + +// TestDryRunVocabularyRollsBack proves the dry-run vocabulary pass does what +// it claims: it WRITES against the live schema (so schema errors surface on a +// dry run, not only on the real one), then rolls everything back. After it, +// the database is byte-for-byte what it was before: still three statuses, no +// cancelled category, no Urgent, no labels, no transitions added — while the +// report names everything the real run would create. +func TestDryRunVocabularyRollsBack(t *testing.T) { + db := newTestDB(t) + meta, records := fidelityContract() + contractPath := writeContract(t, meta, records...) + + l := NewLoader(db, Options{ + WorkspaceKey: "TEST", ContractPath: contractPath, UsersPath: writeUsers(t), + Aliases: map[string]string{"hakerx1@gmail.com": "oskars_germovs@inbox.lv"}, + DryRun: true, BatchSize: 3, + }) + if err := l.LoadContract(); err != nil { + t.Fatalf("load contract: %v", err) + } + if err := l.Resolve(); err != nil { + t.Fatalf("resolve: %v", err) + } + report, err := l.Run(context.Background()) + if err != nil { + t.Fatalf("dry run: %v", err) + } + if !report.DryRun { + t.Errorf("report.DryRun = %v, want true", report.DryRun) + } + if fmt.Sprint(report.CreatedStatuses) != "[Blocked Cancelled Investigating Todo Triage]" { + t.Errorf("would-create statuses = %v, want the five the live pass creates", report.CreatedStatuses) + } + if fmt.Sprint(report.CreatedCategories) != "[Cancelled]" || fmt.Sprint(report.CreatedPriorities) != "[urgent]" { + t.Errorf("would-create vocab = %v / %v, want [Cancelled] and [urgent]", report.CreatedCategories, report.CreatedPriorities) + } + if report.TransitionsAdded != 35 { + t.Errorf("would-add transitions = %d, want 35", report.TransitionsAdded) + } + var statuses, categories, priorities, labels, transitions, items int + if err := db.QueryRow(`SELECT + (SELECT COUNT(*) FROM statuses), (SELECT COUNT(*) FROM status_categories), + (SELECT COUNT(*) FROM priorities), (SELECT COUNT(*) FROM labels), + (SELECT COUNT(*) FROM workflow_transitions), (SELECT COUNT(*) FROM items)`). + Scan(&statuses, &categories, &priorities, &labels, &transitions, &items); err != nil { + t.Fatalf("read catalog counts: %v", err) + } + if statuses != 3 || categories != 3 || priorities != 4 || labels != 0 || transitions != 4 || items != 0 { + t.Errorf("after dry run catalog = statuses:%d categories:%d priorities:%d labels:%d transitions:%d items:%d — want the untouched seed 3/3/4/0/4/0 (a dry run that persists is not a dry run)", + statuses, categories, priorities, labels, transitions, items) + } +} + +func mustNumber(t *testing.T, tx database.Tx) (int, error) { + t.Helper() + // Read the repository's allocator through raw SQL — MAX(items) vs + // MAX(reservations)+1 semantics, the same rule the live allocator runs. + var maxItem sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(workspace_item_number) FROM items WHERE workspace_id = 1`).Scan(&maxItem); err != nil { + return 0, err + } + n := int64(0) + if maxItem.Valid { + n = maxItem.Int64 + } + return int(n + 1), nil +} + +// TestSkippedIssueCommentsAndRerunGaps pins two report facts that only surface +// away from the happy path: a comment whose issue is soft-deleted is counted in +// comments_skipped_unmapped_issue instead of vanishing, and a rerun — where +// every issue is already mapped and nothing is written — still reports the +// source number range and its preserved gaps rather than collapsing to zero. +func TestSkippedIssueCommentsAndRerunGaps(t *testing.T) { + db := newTestDB(t) + meta := `{"type":"meta","meta":{"schema_version":2,"source":"plane","workspace_slug":"solum-designum","project_id":"p-1","project_key":"TEST","dumped_at":"2026-10-26T00:00:00Z"}}` + records := []string{ + `{"type":"issue","issue":{"id":"k-1","sequence_id":1,"name":"kept","state_name":"Done","state_group":"completed","priority":"none","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","created_by_id":"u-gnarl","updated_by_id":"u-gnarl"}}`, + // Soft-deleted, and the highest number in the contract: its number is a + // reserved gap, so the range must extend past it. + `{"type":"issue","issue":{"id":"k-2","sequence_id":2,"name":"removed","state_name":"Done","state_group":"completed","priority":"none","created_at":"2024-01-02T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","deleted_at":"2024-01-03T00:00:00Z","created_by_id":"u-gnarl","updated_by_id":"u-gnarl"}}`, + `{"type":"comment","comment":{"id":"kc-1","issue_id":"k-2","comment_json":"","comment_html":"","text":"orphaned","created_at":"2024-01-03T00:00:00Z","updated_at":"2024-01-03T00:00:00Z","created_by_id":"u-gnarl","access":"INTERNAL"}}`, + } + contractPath := writeContract(t, meta, records...) + newLoader := func() *Loader { + return NewLoader(db, Options{ + WorkspaceKey: "TEST", ContractPath: contractPath, UsersPath: writeUsers(t), + Aliases: map[string]string{"hakerx1@gmail.com": "oskars_germovs@inbox.lv"}, + }) + } + + l := newLoader() + if err := l.LoadContract(); err != nil { + t.Fatalf("load contract: %v", err) + } + if err := l.Resolve(); err != nil { + t.Fatalf("resolve: %v", err) + } + report, err := l.Run(context.Background()) + if err != nil { + t.Fatalf("run: %v", err) + } + if report.IssuesImported != 1 || report.IssuesSoftDeleted != 1 { + t.Errorf("imported/soft-deleted = %d/%d, want 1/1", report.IssuesImported, report.IssuesSoftDeleted) + } + if report.CommentsSkipped != 1 { + t.Errorf("comments skipped = %d, want 1 (a comment on a soft-deleted issue must be counted, not dropped)", report.CommentsSkipped) + } + if report.CommentsImported != 0 || report.CommentsExisting != 0 { + t.Errorf("comments imported/existing = %d/%d, want 0/0", report.CommentsImported, report.CommentsExisting) + } + if report.MaxNumber != 2 { + t.Errorf("max number = %d, want 2 (the soft-deleted #2 still holds its number)", report.MaxNumber) + } + if len(report.NumberGaps) != 1 || report.NumberGaps[0] != 2 { + t.Errorf("gaps = %v, want [2]", report.NumberGaps) + } + + // Rerun: nothing to write, but the range and its gap must still be reported. + l2 := newLoader() + if err := l2.LoadContract(); err != nil { + t.Fatalf("rerun load: %v", err) + } + if err := l2.Resolve(); err != nil { + t.Fatalf("rerun resolve: %v", err) + } + report2, err := l2.Run(context.Background()) + if err != nil { + t.Fatalf("rerun: %v", err) + } + if report2.IssuesImported != 0 || report2.IssuesExisting != 1 { + t.Errorf("rerun imported/existing = %d/%d, want 0/1", report2.IssuesImported, report2.IssuesExisting) + } + if report2.CommentsSkipped != 1 { + t.Errorf("rerun comments skipped = %d, want 1", report2.CommentsSkipped) + } + if report2.MaxNumber != 2 { + t.Errorf("rerun max number = %d, want 2", report2.MaxNumber) + } + if len(report2.NumberGaps) != 1 || report2.NumberGaps[0] != 2 { + t.Errorf("rerun gaps = %v, want [2]", report2.NumberGaps) + } +} + +// TestEmptyCommentIsCountedAndSkippedOnRerun pins that a comment with no body +// is not silently dropped: the run counts it and maps it, so a rerun does not +// process it again. +func TestEmptyCommentIsCountedAndSkippedOnRerun(t *testing.T) { + db := newTestDB(t) + meta := `{"type":"meta","meta":{"schema_version":2,"source":"plane","workspace_slug":"solum-designum","project_id":"p-1","project_key":"TEST","dumped_at":"2026-10-26T00:00:00Z"}}` + records := []string{ + `{"type":"issue","issue":{"id":"i-1","sequence_id":1,"name":"has an empty comment","state_name":"Todo","state_group":"unstarted","priority":"none","created_at":"2024-01-02T03:04:05Z","updated_at":"2024-01-02T03:04:05Z","created_by_id":"","updated_by_id":""}}`, + `{"type":"comment","comment":{"id":"c-empty","issue_id":"i-1","comment_json":"","comment_html":"","text":"","created_at":"2024-01-03T00:00:00Z","updated_at":"2024-01-03T00:00:00Z","created_by_id":"","access":"INTERNAL"}}`, + } + contractPath := writeContract(t, meta, records...) + newLoader := func() *Loader { + return NewLoader(db, Options{WorkspaceKey: "TEST", ContractPath: contractPath}) + } + + l := newLoader() + if err := l.LoadContract(); err != nil { + t.Fatalf("load contract: %v", err) + } + if err := l.Resolve(); err != nil { + t.Fatalf("resolve: %v", err) + } + report, err := l.Run(context.Background()) + if err != nil { + t.Fatalf("run: %v", err) + } + if report.CommentsImported != 0 { + t.Errorf("comments imported = %d, want 0", report.CommentsImported) + } + if report.CommentsSkippedEmpty != 1 { + t.Errorf("empty comments skipped = %d, want 1", report.CommentsSkippedEmpty) + } + + l2 := newLoader() + if err := l2.LoadContract(); err != nil { + t.Fatalf("load contract 2: %v", err) + } + if err := l2.Resolve(); err != nil { + t.Fatalf("resolve 2: %v", err) + } + report2, err := l2.Run(context.Background()) + if err != nil { + t.Fatalf("run 2: %v", err) + } + if report2.CommentsSkippedEmpty != 0 { + t.Errorf("rerun empty comments skipped = %d, want 0 (mapped)", report2.CommentsSkippedEmpty) + } + if report2.CommentsExisting != 1 { + t.Errorf("rerun comments existing = %d, want 1", report2.CommentsExisting) + } +} + +// TestMissingLabelAndParentAreReported pins that references the contract never +// carried land in missing_references, not in collisions: collisions is +// reserved for a source number already owned by a row the importer does not own. +func TestMissingLabelAndParentAreReported(t *testing.T) { + db := newTestDB(t) + meta := `{"type":"meta","meta":{"schema_version":2,"source":"plane","workspace_slug":"solum-designum","project_id":"p-1","project_key":"TEST","dumped_at":"2026-10-26T00:00:00Z"}}` + records := []string{ + `{"type":"issue","issue":{"id":"i-1","sequence_id":1,"name":"dangling refs","state_name":"Todo","state_group":"unstarted","label_ids":["l-missing"],"priority":"none","parent_id":"i-missing","created_at":"2024-01-02T03:04:05Z","updated_at":"2024-01-02T03:04:05Z","created_by_id":"","updated_by_id":""}}`, + } + contractPath := writeContract(t, meta, records...) + + l := NewLoader(db, Options{WorkspaceKey: "TEST", ContractPath: contractPath}) + if err := l.LoadContract(); err != nil { + t.Fatalf("load contract: %v", err) + } + if err := l.Resolve(); err != nil { + t.Fatalf("resolve: %v", err) + } + report, err := l.Run(context.Background()) + if err != nil { + t.Fatalf("run: %v", err) + } + if len(report.MissingReferences) != 2 { + t.Errorf("missing references = %v, want the label and the parent", report.MissingReferences) + } + if len(report.Collisions) != 0 { + t.Errorf("collisions = %v, want none (missing refs are not number collisions)", report.Collisions) + } +} diff --git a/internal/plugins/asset_auth_test.go b/internal/plugins/asset_auth_test.go new file mode 100644 index 0000000..720bac3 --- /dev/null +++ b/internal/plugins/asset_auth_test.go @@ -0,0 +1,251 @@ +//go:build !noplugins + +package plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeAssets(t *testing.T) { + cases := []struct { + name string + assets []Asset + allowPublic bool + wantAuth []RouteAuth + wantErr bool + }{ + { + name: "empty auth defaults to session", + assets: []Asset{{Path: "index.html"}}, + wantAuth: []RouteAuth{RouteAuthSession}, + }, + { + name: "none preserved when public assets are allowed", + assets: []Asset{{Path: "public/*", Auth: RouteAuthNone}}, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone}, + }, + { + name: "none rejected when public assets are disabled", + assets: []Asset{{Path: "public/*", Auth: RouteAuthNone}}, + wantErr: true, + }, + { + name: "admin preserved", + assets: []Asset{{Path: "admin/*", Auth: RouteAuthAdmin}}, + wantAuth: []RouteAuth{RouteAuthAdmin}, + }, + { + name: "unknown auth fails", + assets: []Asset{{Path: "index.html", Auth: "root"}}, + wantErr: true, + }, + { + name: "empty path fails", + assets: []Asset{{Path: ""}}, + wantErr: true, + }, + { + name: "traversal path fails", + assets: []Asset{{Path: "../secret.txt"}}, + wantErr: true, + }, + { + name: "leading slash fails", + assets: []Asset{{Path: "/index.html"}}, + wantErr: true, + }, + { + name: "interior wildcard fails", + assets: []Asset{{Path: "pub/*.js"}}, + wantErr: true, + }, + { + name: "two wildcards fail", + assets: []Asset{{Path: "pub/*/*"}}, + wantErr: true, + }, + { + name: "overlapping entries with different auth fail", + assets: []Asset{ + {Path: "public/*", Auth: RouteAuthNone}, + {Path: "public/index.html", Auth: RouteAuthSession}, + }, + allowPublic: true, + wantErr: true, + }, + { + name: "overlapping entries with the same auth are allowed", + assets: []Asset{ + {Path: "public/*", Auth: RouteAuthNone}, + {Path: "public/index.html", Auth: RouteAuthNone}, + }, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone, RouteAuthNone}, + }, + { + name: "distinct exact paths do not overlap", + assets: []Asset{ + {Path: "public/index.html", Auth: RouteAuthNone}, + {Path: "admin/index.html", Auth: RouteAuthAdmin}, + }, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthAdmin, RouteAuthNone}, // sorted by path + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeAssets(tc.assets, tc.allowPublic) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tc.wantAuth) { + t.Fatalf("assets = %d, want %d", len(got), len(tc.wantAuth)) + } + for i := range got { + if got[i].Auth != tc.wantAuth[i] { + t.Fatalf("asset %d auth = %q, want %q", i, got[i].Auth, tc.wantAuth[i]) + } + } + }) + } +} + +func TestAssetAuthResolvesMostSpecific(t *testing.T) { + assets := []Asset{ + {Path: "*", Auth: RouteAuthSession}, + {Path: "public/*", Auth: RouteAuthNone}, + {Path: "public/index.html", Auth: RouteAuthAdmin}, + } + + cases := []struct { + asset string + want RouteAuth + }{ + {"public/app.js", RouteAuthNone}, + {"public/index.html", RouteAuthAdmin}, + {"admin/app.js", RouteAuthSession}, + {"index.html", RouteAuthSession}, + } + for _, tc := range cases { + if got := assetAuth(assets, tc.asset); got != tc.want { + t.Errorf("assetAuth(%q) = %q, want %q", tc.asset, got, tc.want) + } + } + + if got := assetAuth(nil, "anything"); got != RouteAuthSession { + t.Errorf("assetAuth with no policy = %q, want session", got) + } +} + +func writePluginWithManifest(t *testing.T, root, dirName, manifest string) string { + t.Helper() + pluginDir := filepath.Join(root, dirName) + if err := os.MkdirAll(pluginDir, 0o700); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), minimalCapabilityModule(), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + return pluginDir +} + +func TestLoadPluginGatesPublicAssets(t *testing.T) { + manifest := `{ + "name": "pub", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "assets": [{"path": "public/*", "auth": "none"}] + }` + + t.Run("disabled by default", func(t *testing.T) { + root := t.TempDir() + dir := writePluginWithManifest(t, root, "pub", manifest) + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + if err := m.LoadPlugin(dir); err == nil { + t.Fatal("expected load to fail with public assets disabled") + } + }) + + t.Run("allowed with the option", func(t *testing.T) { + root := t.TempDir() + dir := writePluginWithManifest(t, root, "pub", manifest) + m := NewManager(root, WithAllowPublicRoutes(true)) + t.Cleanup(func() { _ = m.Close() }) + if err := m.LoadPlugin(dir); err != nil { + t.Fatalf("expected load to succeed: %v", err) + } + auth, err := m.GetAssetAuth("pub", "public/app.js") + if err != nil { + t.Fatalf("GetAssetAuth: %v", err) + } + if auth != RouteAuthNone { + t.Fatalf("public asset auth = %q, want none", auth) + } + auth, err = m.GetAssetAuth("pub", "other/app.js") + if err != nil { + t.Fatalf("GetAssetAuth: %v", err) + } + if auth != RouteAuthSession { + t.Fatalf("undeclared asset auth = %q, want session", auth) + } + }) +} + +func TestLoadPluginRejectsAmbiguousAssetAuth(t *testing.T) { + root := t.TempDir() + manifest := `{ + "name": "ambiguous", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "assets": [ + {"path": "public/*", "auth": "none"}, + {"path": "public/index.html", "auth": "session"} + ] + }` + dir := writePluginWithManifest(t, root, "ambiguous", manifest) + m := NewManager(root, WithAllowPublicRoutes(true)) + t.Cleanup(func() { _ = m.Close() }) + if err := m.LoadPlugin(dir); err == nil { + t.Fatal("expected load to fail for ambiguous asset auth") + } else if !strings.Contains(err.Error(), "different auth") { + t.Fatalf("error = %v, want a different-auth diagnostic", err) + } +} + +func TestGetAssetAuthRejectsUnknownAndDisabledPlugin(t *testing.T) { + root := t.TempDir() + dir := writePluginWithManifest(t, root, "demo", `{ + "name": "demo", + "version": "1.0.0", + "entryPoint": "plugin.wasm" + }`) + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + if err := m.LoadPlugin(dir); err != nil { + t.Fatalf("load plugin: %v", err) + } + + if _, err := m.GetAssetAuth("missing", "index.html"); err == nil { + t.Fatal("expected an error for an unknown plugin") + } + if err := m.DisablePlugin("demo"); err != nil { + t.Fatalf("disable plugin: %v", err) + } + if _, err := m.GetAssetAuth("demo", "index.html"); err == nil { + t.Fatal("expected an error for a disabled plugin") + } +} diff --git a/internal/plugins/capabilities_test.go b/internal/plugins/capabilities_test.go index 3c450d8..36432b4 100644 --- a/internal/plugins/capabilities_test.go +++ b/internal/plugins/capabilities_test.go @@ -5,8 +5,10 @@ package plugins import ( "os" "path/filepath" + "strings" "testing" + extism "github.com/extism/go-sdk" "github.com/tetratelabs/wabin/binary" "github.com/tetratelabs/wabin/wasm" ) @@ -78,3 +80,27 @@ func TestManager_MultipleCapabilitiesFromOnePlugin(t *testing.T) { t.Errorf("GetCapabilities length = %d, want %d (capabilities: %v)", len(got), len(want), got) } } + +// TestValidateHostFunctionCatalogRejectsUnmapped pins the fail-closed guard: a +// host function without a permission mapping must fail construction instead of +// shipping callable without a declared permission. +func TestValidateHostFunctionCatalogRejectsUnmapped(t *testing.T) { + if err := validateHostFunctionCatalog([]extism.HostFunction{{Name: "log"}, {Name: "http_fetch"}}); err != nil { + t.Fatalf("mapped and ungated host functions rejected: %v", err) + } + err := validateHostFunctionCatalog([]extism.HostFunction{{Name: "unguarded_fn"}}) + if err == nil { + t.Fatal("unmapped host function accepted, want error") + } + if !strings.Contains(err.Error(), "unguarded_fn") { + t.Fatalf("error = %v, want it to name the offending host function", err) + } +} + +// TestNewManagerAcceptsBuiltCatalog is the smoke test for the panic guard: the +// shipped catalog is valid, so constructing a manager must not panic. +func TestNewManagerAcceptsBuiltCatalog(t *testing.T) { + if manager := NewManager(t.TempDir()); manager == nil { + t.Fatal("NewManager returned nil") + } +} diff --git a/internal/plugins/cli_host_test.go b/internal/plugins/cli_host_test.go index ee8a816..23db5ca 100644 --- a/internal/plugins/cli_host_test.go +++ b/internal/plugins/cli_host_test.go @@ -10,8 +10,6 @@ import ( "testing" extism "github.com/extism/go-sdk" - "github.com/tetratelabs/wabin/binary" - "github.com/tetratelabs/wabin/wasm" "windshift/internal/repository" "windshift/internal/testutils" ) @@ -134,7 +132,10 @@ func callCLIExecTestPlugin(t *testing.T, plugin *extism.Plugin, request CLIExecR if err != nil { t.Fatalf("marshal cli_exec request: %v", err) } - exitCode, output, err := plugin.Call("run", payload) + // The host function enforces the calling plugin's effective permissions, so + // the directly-constructed test plugin must carry a plugin context. + ctx := withPluginPermissions(withPluginName(context.Background(), "cli-exec-test"), []string{PermissionProcessExec}) + exitCode, output, err := plugin.CallWithContext(ctx, "run", payload) if err != nil { t.Fatalf("call cli_exec test plugin: %v", err) } @@ -149,41 +150,6 @@ func callCLIExecTestPlugin(t *testing.T, plugin *extism.Plugin, request CLIExecR return response } -// cliExecTestModule builds the minimal Extism guest needed to pass its input -// to cli_exec and expose the host response as plugin output. -func cliExecTestModule() []byte { - i32, i64 := wasm.ValueTypeI32, wasm.ValueTypeI64 - module := &wasm.Module{ - TypeSection: []*wasm.FunctionType{ - {Results: []wasm.ValueType{i64}}, - {Params: []wasm.ValueType{i64}, Results: []wasm.ValueType{i64}}, - {Params: []wasm.ValueType{i64, i64}}, - {Results: []wasm.ValueType{i32}}, - }, - ImportSection: []*wasm.Import{ - {Module: "extism:host/env", Name: "input_offset", Type: wasm.ExternTypeFunc, DescFunc: 0}, - {Module: "extism:host/env", Name: "length", Type: wasm.ExternTypeFunc, DescFunc: 1}, - {Module: "extism:host/env", Name: "output_set", Type: wasm.ExternTypeFunc, DescFunc: 2}, - {Module: "extism:host/user", Name: "cli_exec", Type: wasm.ExternTypeFunc, DescFunc: 1}, - }, - FunctionSection: []wasm.Index{3}, - ExportSection: []*wasm.Export{ - {Name: "run", Type: wasm.ExternTypeFunc, Index: 4}, - }, - CodeSection: []*wasm.Code{{ - LocalTypes: []wasm.ValueType{i64}, - Body: []byte{ - wasm.OpcodeCall, 0, - wasm.OpcodeCall, 3, - wasm.OpcodeLocalSet, 0, - wasm.OpcodeLocalGet, 0, - wasm.OpcodeLocalGet, 0, - wasm.OpcodeCall, 1, - wasm.OpcodeCall, 2, - wasm.OpcodeI32Const, 0, - wasm.OpcodeEnd, - }, - }}, - } - return binary.EncodeModule(module) -} +// cliExecTestModule is retained as a named reference to the shared host-call +// guest so existing cli_exec tests read clearly. +func cliExecTestModule() []byte { return hostCallTestModule("cli_exec") } diff --git a/internal/plugins/editor_extensions_test.go b/internal/plugins/editor_extensions_test.go new file mode 100644 index 0000000..16ed59f --- /dev/null +++ b/internal/plugins/editor_extensions_test.go @@ -0,0 +1,146 @@ +//go:build !noplugins + +package plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeEditorExtensions(t *testing.T) { + cases := []struct { + name string + ext Extension + wantErr string + wantMode string + }{ + { + name: "defaultsModeToToggle", + ext: Extension{Point: ExtensionPointEditorMarkdown, ID: "plaintext", Component: "editor.html", EntityTypes: []string{"item", "page"}}, + wantMode: EditorModeToggle, + }, + { + name: "acceptsReplace", + ext: Extension{Point: ExtensionPointEditorMarkdown, ID: "plaintext", Component: "editor.html", Mode: EditorModeReplace}, + wantMode: EditorModeReplace, + }, + { + name: "requiresComponent", + ext: Extension{Point: ExtensionPointEditorMarkdown, ID: "plaintext"}, + wantErr: "component is required", + }, + { + name: "rejectsUnknownMode", + ext: Extension{Point: ExtensionPointEditorMarkdown, ID: "plaintext", Component: "editor.html", Mode: "inline"}, + wantErr: "mode must be", + }, + { + name: "rejectsUnknownEntityType", + ext: Extension{Point: ExtensionPointEditorMarkdown, ID: "plaintext", Component: "editor.html", EntityTypes: []string{"asset"}}, + wantErr: "unknown entity type", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + extensions := []Extension{tc.ext} + err := normalizeEditorExtensions(extensions) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeEditorExtensions() error = %v", err) + } + if extensions[0].Mode != tc.wantMode { + t.Fatalf("mode = %q, want %q", extensions[0].Mode, tc.wantMode) + } + }) + } + + t.Run("leavesOtherPointsAlone", func(t *testing.T) { + extensions := []Extension{{Point: "admin.tab", ID: "react-demo", Component: "index.html"}} + if err := normalizeEditorExtensions(extensions); err != nil { + t.Fatalf("normalizeEditorExtensions() error = %v", err) + } + if extensions[0].Mode != "" { + t.Fatalf("non-editor mode = %q, want empty", extensions[0].Mode) + } + }) +} + +func writeEditorPlugin(t *testing.T, root, name, extensionJSON string) string { + t.Helper() + pluginDir := filepath.Join(root, name) + if err := os.MkdirAll(pluginDir, 0o700); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + manifest := []byte(`{ + "name": "` + name + `", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "extensions": [` + extensionJSON + `] + }`) + if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), manifest, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), minimalCapabilityModule(), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + return pluginDir +} + +func TestGetExtensionsLicenseGating(t *testing.T) { + root := t.TempDir() + writeEditorPlugin(t, root, "windshift-plaintext", `{ + "point": "editor.markdown", + "id": "plaintext", + "label": "Plain Markdown", + "component": "editor.html", + "entityTypes": ["item", "page"] + }`) + + // Unlicensed plugins must not advertise an editor provider. + unlicensed := loadManager(t, root, verifierForTest) + if got := unlicensed.GetExtensions()[ExtensionPointEditorMarkdown]; len(got) != 0 { + t.Fatalf("unlicensed provider advertised: %+v", got) + } + + writeLicense(t, filepath.Join(root, "windshift-plaintext"), validToken) + licensed := loadManager(t, root, verifierForTest) + providers := licensed.GetExtensions()[ExtensionPointEditorMarkdown] + if len(providers) != 1 { + t.Fatalf("licensed providers = %+v, want one", providers) + } + if providers[0].Mode != EditorModeToggle { + t.Fatalf("mode = %q, want default %q", providers[0].Mode, EditorModeToggle) + } + if providers[0].PluginName != "windshift-plaintext" { + t.Fatalf("pluginName = %q, want windshift-plaintext", providers[0].PluginName) + } + if providers[0].EntityTypes[0] != "item" || providers[0].EntityTypes[1] != "page" { + t.Fatalf("entityTypes = %v, want [item page]", providers[0].EntityTypes) + } +} + +func TestLoadPluginRejectsInvalidEditorExtension(t *testing.T) { + root := t.TempDir() + writeEditorPlugin(t, root, "bad-editor", `{ + "point": "editor.markdown", + "id": "bad", + "component": "editor.html", + "mode": "sideways" + }`) + + manager := NewManager(root) + t.Cleanup(func() { _ = manager.Close() }) + if err := manager.LoadPlugins(); err != nil { + t.Fatalf("LoadPlugins() error = %v", err) + } + if _, ok := manager.GetExtensions()[ExtensionPointEditorMarkdown]; ok { + t.Fatal("invalid editor provider was loaded") + } +} diff --git a/internal/plugins/manager_stub_test.go b/internal/plugins/manager_stub_test.go new file mode 100644 index 0000000..85a707d --- /dev/null +++ b/internal/plugins/manager_stub_test.go @@ -0,0 +1,47 @@ +//go:build noplugins + +package plugins + +import ( + "os" + "path/filepath" + "testing" +) + +// TestStubGetAssetConfinement covers the noplugins build variant: asset paths +// must stay inside the plugin's assets directory and plugin names must be +// validated even though no plugin is ever loaded. +func TestStubGetAssetConfinement(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "test-plugin") + assetsDir := filepath.Join(pluginDir, "assets") + if err := os.MkdirAll(assetsDir, 0o750); err != nil { + t.Fatalf("create assets dir: %v", err) + } + if err := os.WriteFile(filepath.Join(assetsDir, "ok.txt"), []byte("ok"), 0o600); err != nil { + t.Fatalf("write asset: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "secret.txt"), []byte("secret"), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + + m := NewManager(root) + + data, _, err := m.GetAsset("test-plugin", "ok.txt") + if err != nil { + t.Fatalf("read valid asset: %v", err) + } + if string(data) != "ok" { + t.Fatalf("asset = %q, want ok", data) + } + + if _, _, err := m.GetAsset("test-plugin", "../secret.txt"); err == nil { + t.Fatal("expected traversal asset path to be rejected") + } + if _, _, err := m.GetAsset("../test-plugin", "ok.txt"); err == nil { + t.Fatal("expected invalid plugin name to be rejected") + } + if _, _, err := m.GetAsset("test-plugin", "/etc/passwd"); err == nil { + t.Fatal("expected absolute asset path to be rejected") + } +} diff --git a/internal/plugins/permissions_test.go b/internal/plugins/permissions_test.go new file mode 100644 index 0000000..9803a89 --- /dev/null +++ b/internal/plugins/permissions_test.go @@ -0,0 +1,333 @@ +//go:build test && !noplugins + +package plugins + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "windshift/internal/repository" + "windshift/internal/testutils" + + "github.com/tetratelabs/wabin/binary" + "github.com/tetratelabs/wabin/wasm" +) + +// hostCallTestModule builds the minimal Extism guest that forwards its input to +// the named host function and exposes the host response as plugin output. +func hostCallTestModule(hostFunction string) []byte { + i32, i64 := wasm.ValueTypeI32, wasm.ValueTypeI64 + module := &wasm.Module{ + TypeSection: []*wasm.FunctionType{ + {Results: []wasm.ValueType{i64}}, + {Params: []wasm.ValueType{i64}, Results: []wasm.ValueType{i64}}, + {Params: []wasm.ValueType{i64, i64}}, + {Results: []wasm.ValueType{i32}}, + }, + ImportSection: []*wasm.Import{ + {Module: "extism:host/env", Name: "input_offset", Type: wasm.ExternTypeFunc, DescFunc: 0}, + {Module: "extism:host/env", Name: "length", Type: wasm.ExternTypeFunc, DescFunc: 1}, + {Module: "extism:host/env", Name: "output_set", Type: wasm.ExternTypeFunc, DescFunc: 2}, + {Module: "extism:host/user", Name: hostFunction, Type: wasm.ExternTypeFunc, DescFunc: 1}, + }, + FunctionSection: []wasm.Index{3}, + ExportSection: []*wasm.Export{ + {Name: "run", Type: wasm.ExternTypeFunc, Index: 4}, + }, + CodeSection: []*wasm.Code{{ + LocalTypes: []wasm.ValueType{i64}, + Body: []byte{ + wasm.OpcodeCall, 0, + wasm.OpcodeCall, 3, + wasm.OpcodeLocalSet, 0, + wasm.OpcodeLocalGet, 0, + wasm.OpcodeLocalGet, 0, + wasm.OpcodeCall, 1, + wasm.OpcodeCall, 2, + wasm.OpcodeI32Const, 0, + wasm.OpcodeEnd, + }, + }}, + } + return binary.EncodeModule(module) +} + +// logTestModule builds a guest that calls the always-available log host function +// and returns success without setting output. +func logTestModule() []byte { + i32, i64 := wasm.ValueTypeI32, wasm.ValueTypeI64 + module := &wasm.Module{ + TypeSection: []*wasm.FunctionType{ + {Results: []wasm.ValueType{i64}}, + {Params: []wasm.ValueType{i64}}, + {Results: []wasm.ValueType{i32}}, + }, + ImportSection: []*wasm.Import{ + {Module: "extism:host/env", Name: "input_offset", Type: wasm.ExternTypeFunc, DescFunc: 0}, + {Module: "extism:host/user", Name: "log", Type: wasm.ExternTypeFunc, DescFunc: 1}, + }, + FunctionSection: []wasm.Index{2}, + ExportSection: []*wasm.Export{ + {Name: "run", Type: wasm.ExternTypeFunc, Index: 2}, + }, + CodeSection: []*wasm.Code{{ + LocalTypes: []wasm.ValueType{i64}, + Body: []byte{ + wasm.OpcodeCall, 0, + wasm.OpcodeLocalSet, 0, + wasm.OpcodeLocalGet, 0, + wasm.OpcodeCall, 1, + wasm.OpcodeI32Const, 0, + wasm.OpcodeEnd, + }, + }}, + } + return binary.EncodeModule(module) +} + +// writePluginWithPermissions writes a plugin directory with a manifest that +// declares permissions and the given WASM module. +func writePluginWithPermissions(t *testing.T, root, name string, permissions []string, module []byte) string { + t.Helper() + + pluginDir := filepath.Join(root, name) + if err := os.MkdirAll(pluginDir, 0o700); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + if permissions == nil { + permissions = []string{} + } + permissionsJSON, err := json.Marshal(permissions) + if err != nil { + t.Fatalf("marshal permissions: %v", err) + } + manifest := []byte(`{"name":"` + name + `","version":"1.0.0","entryPoint":"plugin.wasm","permissions":` + string(permissionsJSON) + `}`) + if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), manifest, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), module, 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + return pluginDir +} + +func loadPluginWithPermissions(t *testing.T, manager *Manager, root, name string, permissions []string, module []byte) { + t.Helper() + if err := manager.LoadPlugin(writePluginWithPermissions(t, root, name, permissions, module)); err != nil { + t.Fatalf("load plugin %s: %v", name, err) + } +} + +func callHost[T any](t *testing.T, manager *Manager, pluginName string, payload any) T { + t.Helper() + + output, err := manager.CallPluginFunction(pluginName, "run", payload) + if err != nil { + t.Fatalf("call %s: %v", pluginName, err) + } + var response T + if err := json.Unmarshal(output, &response); err != nil { + t.Fatalf("unmarshal %s response %q: %v", pluginName, output, err) + } + return response +} + +// TestHostFunctionPermissionMapping guards the catalog: every host function the +// manager registers must either be explicitly ungated or carry a permission, so +// a new host function cannot ship without enforcement. +func TestHostFunctionPermissionMapping(t *testing.T) { + manager := NewManager(t.TempDir()) + t.Cleanup(func() { _ = manager.Close() }) + + for _, hostFunction := range manager.buildHostFunctions() { + if _, ungated := ungatedHostFunctions[hostFunction.Name]; ungated { + continue + } + if _, mapped := hostFunctionPermissions[hostFunction.Name]; !mapped { + t.Errorf("host function %q is registered but has no permission mapping", hostFunction.Name) + } + } + for hostFunction, permission := range hostFunctionPermissions { + if _, known := knownPermissions[permission]; !known { + t.Errorf("host function %q maps to unknown permission %q", hostFunction, permission) + } + } + for hostFunction := range ungatedHostFunctions { + if hostFunction != "log" { + t.Errorf("unexpected ungated host function %q", hostFunction) + } + } +} + +func TestNormalizePermissions(t *testing.T) { + tests := []struct { + name string + declared []string + want []string + wantErr string + }{ + {name: "empty", declared: nil, want: nil}, + {name: "dedupes and sorts", declared: []string{PermissionNetworkEgress, PermissionProcessExec, PermissionNetworkEgress}, want: []string{PermissionNetworkEgress, PermissionProcessExec}}, + {name: "trims whitespace", declared: []string{" process.exec "}, want: []string{PermissionProcessExec}}, + {name: "ignores blank entries", declared: []string{"", " "}, want: nil}, + {name: "unknown name fails", declared: []string{"process.exe"}, wantErr: `unknown permission "process.exe"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizePermissions(tt.declared) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("normalizePermissions(%v) error = %v, want %q", tt.declared, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizePermissions(%v) unexpected error: %v", tt.declared, err) + } + if len(got) != len(tt.want) { + t.Fatalf("normalizePermissions(%v) = %v, want %v", tt.declared, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("normalizePermissions(%v) = %v, want %v", tt.declared, got, tt.want) + } + } + }) + } +} + +func TestPluginWithoutPermissionsDeniesGatedHostFunctions(t *testing.T) { + root := t.TempDir() + manager := NewManager(root) + t.Cleanup(func() { _ = manager.Close() }) + + loadPluginWithPermissions(t, manager, root, "denied-cli", nil, hostCallTestModule("cli_exec")) + loadPluginWithPermissions(t, manager, root, "denied-http", nil, hostCallTestModule("http_fetch")) + + cliResponse := callHost[CLIExecResponse](t, manager, "denied-cli", CLIExecRequest{Command: "echo"}) + if cliResponse.Status != "error" || cliResponse.Error != permissionDeniedMessage(PermissionProcessExec) { + t.Fatalf("cli_exec = %+v, want permission denied", cliResponse) + } + + httpResponse := callHost[HTTPFetchResponse](t, manager, "denied-http", HTTPFetchRequest{URL: "http://example.com"}) + if httpResponse.Status != http.StatusForbidden { + t.Fatalf("http_fetch status = %d, want %d", httpResponse.Status, http.StatusForbidden) + } + if string(httpResponse.Body) != permissionDeniedMessage(PermissionNetworkEgress) { + t.Fatalf("http_fetch body = %q, want permission denied", httpResponse.Body) + } +} + +func TestPluginWithoutPermissionsStillLogs(t *testing.T) { + root := t.TempDir() + manager := NewManager(root) + t.Cleanup(func() { _ = manager.Close() }) + + loadPluginWithPermissions(t, manager, root, "logger", nil, logTestModule()) + + if _, err := manager.CallPluginFunction("logger", "run", LogRequest{Level: "info", Message: "hello"}); err != nil { + t.Fatalf("log host function failed: %v", err) + } +} + +func TestProcessExecPermissionRunsCLIAndDeniesNetwork(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + + settings := repository.NewSystemSettingRepository(tdb.Database) + if err := settings.Upsert( + "plugin_cli_exec_enabled", "true", "boolean", + "Allow plugins to execute CLI commands", "security", + ); err != nil { + t.Fatalf("enable plugin CLI execution: %v", err) + } + + root := t.TempDir() + manager := NewManager(root, WithDatabase(tdb.Database)) + t.Cleanup(func() { _ = manager.Close() }) + + loadPluginWithPermissions(t, manager, root, "cli-allowed", []string{PermissionProcessExec}, hostCallTestModule("cli_exec")) + loadPluginWithPermissions(t, manager, root, "http-denied", []string{PermissionProcessExec}, hostCallTestModule("http_fetch")) + + marker := filepath.Join(t.TempDir(), "marker") + cliResponse := callHost[CLIExecResponse](t, manager, "cli-allowed", helperProcessRequest(marker)) + if cliResponse.Status != "ok" || cliResponse.ExitCode != 0 { + t.Fatalf("cli_exec = %+v, want ok", cliResponse) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("cli_exec did not run the command: %v", err) + } + + httpResponse := callHost[HTTPFetchResponse](t, manager, "http-denied", HTTPFetchRequest{URL: "http://example.com"}) + if httpResponse.Status != http.StatusForbidden || string(httpResponse.Body) != permissionDeniedMessage(PermissionNetworkEgress) { + t.Fatalf("http_fetch = %+v, want permission denied", httpResponse) + } +} + +func TestUnknownPermissionFailsLoad(t *testing.T) { + root := t.TempDir() + manager := NewManager(root) + t.Cleanup(func() { _ = manager.Close() }) + + pluginDir := writePluginWithPermissions(t, root, "bad-permission", []string{"process.exe"}, hostCallTestModule("cli_exec")) + + err := manager.LoadPlugin(pluginDir) + if err == nil { + t.Fatal("LoadPlugin with unknown permission succeeded, want error") + } + if !strings.Contains(err.Error(), `unknown permission "process.exe"`) { + t.Fatalf("LoadPlugin error = %v, want unknown permission naming process.exe", err) + } + if _, exists := manager.GetPlugin("bad-permission"); exists { + t.Error("plugin with unknown permission was registered") + } +} + +func TestUnlicensedPluginGetsNoPermissions(t *testing.T) { + root := t.TempDir() + manager := NewManager(root, WithLicenseVerifier(verifierForTest)) + t.Cleanup(func() { _ = manager.Close() }) + + loadPluginWithPermissions(t, manager, root, "unlicensed", []string{PermissionProcessExec}, hostCallTestModule("cli_exec")) + + plugin, ok := manager.GetPlugin("unlicensed") + if !ok { + t.Fatal("plugin not loaded") + } + if len(plugin.Manifest.Permissions) != 1 || plugin.Manifest.Permissions[0] != PermissionProcessExec { + t.Fatalf("requested permissions = %v, want [%s]", plugin.Manifest.Permissions, PermissionProcessExec) + } + if len(plugin.Permissions) != 0 { + t.Fatalf("effective permissions = %v, want none for an unlicensed plugin", plugin.Permissions) + } + + response := callHost[CLIExecResponse](t, manager, "unlicensed", CLIExecRequest{Command: "echo"}) + if response.Status != "error" || response.Error != permissionDeniedMessage(PermissionProcessExec) { + t.Fatalf("cli_exec = %+v, want permission denied", response) + } +} + +func TestLicensedPluginKeepsDeclaredPermissions(t *testing.T) { + root := t.TempDir() + manager := NewManager(root, WithLicenseVerifier(verifierForTest)) + t.Cleanup(func() { _ = manager.Close() }) + + pluginDir := writePluginWithPermissions(t, root, "licensed", []string{PermissionProcessExec}, hostCallTestModule("cli_exec")) + writeLicense(t, pluginDir, validToken) + + if err := manager.LoadPlugin(pluginDir); err != nil { + t.Fatalf("load licensed plugin: %v", err) + } + + plugin, ok := manager.GetPlugin("licensed") + if !ok { + t.Fatal("plugin not loaded") + } + if len(plugin.Permissions) != 1 || plugin.Permissions[0] != PermissionProcessExec { + t.Fatalf("effective permissions = %v, want [%s]", plugin.Permissions, PermissionProcessExec) + } +} diff --git a/internal/plugins/route_auth_test.go b/internal/plugins/route_auth_test.go new file mode 100644 index 0000000..de574a0 --- /dev/null +++ b/internal/plugins/route_auth_test.go @@ -0,0 +1,407 @@ +//go:build !noplugins + +package plugins + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "windshift/internal/contextkeys" + "windshift/internal/models" +) + +func TestNormalizePluginRoutes(t *testing.T) { + cases := []struct { + name string + routes []Route + allowPublic bool + wantAuth []RouteAuth + wantErr bool + }{ + { + name: "empty defaults to session", + routes: []Route{{Method: "GET", Path: "/x"}}, + wantAuth: []RouteAuth{RouteAuthSession}, + }, + { + name: "none preserved when public routes are allowed", + routes: []Route{{Method: "GET", Path: "/x", Auth: RouteAuthNone}}, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone}, + }, + { + name: "none rejected when public routes are disabled", + routes: []Route{{Method: "GET", Path: "/x", Auth: RouteAuthNone}}, + wantErr: true, + }, + { + name: "admin preserved", + routes: []Route{{Method: "GET", Path: "/x", Auth: RouteAuthAdmin}}, + wantAuth: []RouteAuth{RouteAuthAdmin}, + }, + { + name: "unknown fails", + routes: []Route{{Method: "GET", Path: "/x", Auth: "root"}}, + wantErr: true, + }, + { + name: "wildcard overlapping exact with different auth fails", + routes: []Route{ + {Method: "GET", Path: "/x*", Auth: RouteAuthNone}, + {Method: "GET", Path: "/x/y", Auth: RouteAuthSession}, + }, + allowPublic: true, + wantErr: true, + }, + { + name: "same path with different auth fails", + routes: []Route{ + {Method: "GET", Path: "/x", Auth: RouteAuthNone}, + {Method: "GET", Path: "/x", Auth: RouteAuthSession}, + }, + allowPublic: true, + wantErr: true, + }, + { + name: "overlap with the same auth is allowed", + routes: []Route{ + {Method: "GET", Path: "/x*", Auth: RouteAuthNone}, + {Method: "GET", Path: "/x/y", Auth: RouteAuthNone}, + }, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone, RouteAuthNone}, + }, + { + name: "different methods do not overlap", + routes: []Route{ + {Method: "GET", Path: "/x", Auth: RouteAuthNone}, + {Method: "POST", Path: "/x", Auth: RouteAuthSession}, + }, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone, RouteAuthSession}, + }, + { + name: "distinct exact paths do not overlap", + routes: []Route{ + {Method: "GET", Path: "/x", Auth: RouteAuthNone}, + {Method: "GET", Path: "/x/y", Auth: RouteAuthSession}, + }, + allowPublic: true, + wantAuth: []RouteAuth{RouteAuthNone, RouteAuthSession}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizePluginRoutes(tc.routes, tc.allowPublic) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tc.wantAuth) { + t.Fatalf("routes = %d, want %d", len(got), len(tc.wantAuth)) + } + for i := range got { + if got[i].Auth != tc.wantAuth[i] { + t.Fatalf("route %d auth = %q, want %q", i, got[i].Auth, tc.wantAuth[i]) + } + } + }) + } +} + +func TestLoadPluginRejectsInvalidRouteAuth(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "bad-auth") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), minimalMultiCapabilityModule(), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + manifest := `{ + "name": "bad-auth", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "routes": [{"method": "GET", "path": "/x", "auth": "root"}] + }` + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + m := NewManager(root) + if err := m.LoadPlugin(dir); err == nil { + t.Fatal("expected load to fail for an unknown route auth") + } +} + +func TestLoadPluginGatesPublicRoutes(t *testing.T) { + write := func(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "pub") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), minimalMultiCapabilityModule(), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + manifest := `{ + "name": "pub", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "routes": [{"method": "GET", "path": "/x", "auth": "none"}] + }` + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + return root, dir + } + + t.Run("disabled by default", func(t *testing.T) { + root, dir := write(t) + m := NewManager(root) + if err := m.LoadPlugin(dir); err == nil { + t.Fatal("expected load to fail with public routes disabled") + } + }) + + t.Run("allowed with the option", func(t *testing.T) { + root, dir := write(t) + m := NewManager(root, WithAllowPublicRoutes(true)) + if err := m.LoadPlugin(dir); err != nil { + t.Fatalf("expected load to succeed: %v", err) + } + }) +} + +func TestFindRouteSelectsMostSpecific(t *testing.T) { + routes := []Route{ + {Method: "GET", Path: "/x*", Auth: RouteAuthNone}, + {Method: "GET", Path: "/x/y", Auth: RouteAuthSession}, + } + route, ok := findRoute(routes, "GET", "/x/y") + if !ok { + t.Fatal("expected a match") + } + if route.Auth != RouteAuthSession { + t.Fatalf("matched auth = %q, want session (exact beats wildcard)", route.Auth) + } +} + +type fakePluginSource struct { + plugin *LoadedPlugin + last *HTTPRequest + response *HTTPResponse +} + +func (f *fakePluginSource) GetPlugin(name string) (*LoadedPlugin, bool) { + if f.plugin != nil && f.plugin.Manifest.Name == name { + return f.plugin, true + } + return nil, false +} + +func (f *fakePluginSource) HandleRequest(name string, req *HTTPRequest) (*HTTPResponse, error) { + f.last = req + if f.response != nil { + return f.response, nil + } + return &HTTPResponse{StatusCode: http.StatusOK, Body: `{"ok":true}`}, nil +} + +type fakePrincipalSource struct { + admins map[int]bool + groups map[int][]int +} + +func (f fakePrincipalSource) IsSystemAdmin(userID int) (bool, error) { return f.admins[userID], nil } +func (f fakePrincipalSource) GetGroupMemberships(userID int) ([]int, error) { + return f.groups[userID], nil +} + +type fakeCSRF struct{ allow bool } + +func (f fakeCSRF) Allows(*http.Request) bool { return f.allow } + +func testRequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := r.Header.Get("X-Test-User") + if raw == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + user := &models.User{} + if raw == "admin" { + user.ID = 7 + user.IsSystemAdmin = true + } else { + _, _ = fmt.Sscanf(raw, "%d", &user.ID) + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextkeys.User, user))) + }) +} + +func testRequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _ := r.Context().Value(contextkeys.User).(*models.User) + if user == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if !user.IsSystemAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func newTestRouter(t *testing.T, source PluginSource, csrfAllows bool) *http.ServeMux { + t.Helper() + router := NewRouter(source, RouterDeps{ + RequireAuth: testRequireAuth, + RequireAdmin: testRequireAdmin, + CSRF: fakeCSRF{allow: csrfAllows}, + Permissions: fakePrincipalSource{ + admins: map[int]bool{7: true}, + groups: map[int][]int{3: {1, 2}}, + }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + mux := http.NewServeMux() + router.RegisterRoutes(mux) + return mux +} + +func routeAuthPlugin() *LoadedPlugin { + return &LoadedPlugin{ + Manifest: PluginManifest{Name: "demo"}, + Enabled: true, + Routes: []Route{ + {Method: "GET", Path: "/private", Auth: RouteAuthSession}, + {Method: "POST", Path: "/private", Auth: RouteAuthSession}, + {Method: "GET", Path: "/admin-only", Auth: RouteAuthAdmin}, + {Method: "GET", Path: "/public", Auth: RouteAuthNone}, + {Method: "POST", Path: "/public", Auth: RouteAuthNone}, + }, + } +} + +func doRequest(mux *http.ServeMux, method, path string, mutate func(*http.Request)) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + if mutate != nil { + mutate(req) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestPluginRouterSessionAuth(t *testing.T) { + source := &fakePluginSource{plugin: routeAuthPlugin()} + mux := newTestRouter(t, source, true) + + if rec := doRequest(mux, "GET", "/api/plugins/demo/private", nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous session route = %d, want 401", rec.Code) + } + + rec := doRequest(mux, "GET", "/api/plugins/demo/private", func(r *http.Request) { + r.Header.Set("X-Test-User", "3") + }) + if rec.Code != http.StatusOK { + t.Fatalf("authenticated session route = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if source.last == nil || source.last.Principal == nil { + t.Fatal("plugin did not receive a principal") + } + principal := source.last.Principal + if principal.UserID != 3 || principal.IsAdmin { + t.Fatalf("principal = %+v, want user 3 non-admin", principal) + } + if len(principal.GroupIDs) != 2 || principal.GroupIDs[0] != 1 || principal.GroupIDs[1] != 2 { + t.Fatalf("principal groups = %v, want [1 2]", principal.GroupIDs) + } +} + +func TestPluginRouterSessionCSRF(t *testing.T) { + source := &fakePluginSource{plugin: routeAuthPlugin()} + mux := newTestRouter(t, source, false) + + rec := doRequest(mux, "POST", "/api/plugins/demo/private", func(r *http.Request) { + r.Header.Set("X-Test-User", "3") + r.Header.Set("Origin", "https://evil.example") + }) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-site session route = %d, want 403", rec.Code) + } +} + +func TestPluginRouterAdminAuth(t *testing.T) { + source := &fakePluginSource{plugin: routeAuthPlugin()} + mux := newTestRouter(t, source, true) + + rec := doRequest(mux, "GET", "/api/plugins/demo/admin-only", func(r *http.Request) { + r.Header.Set("X-Test-User", "3") + }) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-admin admin route = %d, want 403", rec.Code) + } + + rec = doRequest(mux, "GET", "/api/plugins/demo/admin-only", func(r *http.Request) { + r.Header.Set("X-Test-User", "admin") + }) + if rec.Code != http.StatusOK { + t.Fatalf("admin route = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if source.last.Principal == nil || !source.last.Principal.IsAdmin { + t.Fatalf("principal = %+v, want admin", source.last.Principal) + } +} + +func TestPluginRouterPublicRouteStripsCredentials(t *testing.T) { + source := &fakePluginSource{plugin: routeAuthPlugin()} + mux := newTestRouter(t, source, false) + + rec := doRequest(mux, "GET", "/api/plugins/demo/public", func(r *http.Request) { + r.Header.Set("Cookie", "windshift_session=secret") + r.Header.Set("Authorization", "Bearer secret") + }) + if rec.Code != http.StatusOK { + t.Fatalf("public route = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if source.last.Principal != nil { + t.Fatalf("public route principal = %+v, want nil", source.last.Principal) + } + for key := range source.last.Headers { + if strings.EqualFold(key, "Cookie") || strings.EqualFold(key, "Authorization") { + t.Fatalf("public route forwarded credential header %q", key) + } + } +} + +func TestPluginRouterRejectsOversizedBody(t *testing.T) { + source := &fakePluginSource{plugin: routeAuthPlugin()} + mux := newTestRouter(t, source, true) + + body := strings.Repeat("a", maxPluginRequestBodyBytes+1024) + req := httptest.NewRequest("POST", "/api/plugins/demo/public", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized body = %d, want 413", rec.Code) + } +} diff --git a/internal/plugins/router_headers_test.go b/internal/plugins/router_headers_test.go new file mode 100644 index 0000000..58a1614 --- /dev/null +++ b/internal/plugins/router_headers_test.go @@ -0,0 +1,78 @@ +//go:build !noplugins + +package plugins + +import ( + "net/http" + "testing" +) + +func TestForbiddenPluginResponseHeader(t *testing.T) { + cases := map[string]bool{ + "Set-Cookie": true, + "set-cookie": true, + "Set-Cookie2": true, + "Access-Control-Allow-Origin": true, + "access-control-allow-headers": true, + "Content-Length": true, + "Transfer-Encoding": true, + "Strict-Transport-Security": true, + "Content-Security-Policy": true, + "X-Frame-Options": true, + "Clear-Site-Data": true, + "Connection": true, + "Upgrade": true, + "Content-Disposition": false, + "Location": false, + "Cache-Control": false, + "ETag": false, + "Content-Type": false, + "X-Custom": false, + } + for header, want := range cases { + if got := forbiddenPluginResponseHeader(header); got != want { + t.Errorf("forbiddenPluginResponseHeader(%q) = %v, want %v", header, got, want) + } + } +} + +func TestPluginRouterDropsForbiddenResponseHeaders(t *testing.T) { + source := &fakePluginSource{ + plugin: routeAuthPlugin(), + response: &HTTPResponse{ + StatusCode: http.StatusOK, + Headers: map[string]string{ + "Set-Cookie": "session=evil", + "Access-Control-Allow-Origin": "*", + "Content-Length": "3", + "Content-Disposition": "attachment; filename=report.csv", + "Cache-Control": "no-store", + "X-Custom": "kept", + "Content-Type": "text/plain", + }, + Body: "ok", + }, + } + mux := newTestRouter(t, source, false) + + rec := doRequest(mux, "GET", "/api/plugins/demo/public", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + + for _, header := range []string{"Set-Cookie", "Access-Control-Allow-Origin", "Content-Length"} { + if got := rec.Header().Get(header); got != "" { + t.Errorf("%s = %q, want it dropped", header, got) + } + } + for header, want := range map[string]string{ + "Content-Disposition": "attachment; filename=report.csv", + "Cache-Control": "no-store", + "X-Custom": "kept", + "Content-Type": "text/plain", + } { + if got := rec.Header().Get(header); got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } +} diff --git a/internal/plugins/upload_hardening_test.go b/internal/plugins/upload_hardening_test.go new file mode 100644 index 0000000..717ab6d --- /dev/null +++ b/internal/plugins/upload_hardening_test.go @@ -0,0 +1,311 @@ +//go:build !noplugins + +package plugins + +import ( + "archive/zip" + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type zipEntry struct { + name string + data []byte +} + +func buildZip(t *testing.T, entries []zipEntry) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + for _, e := range entries { + entry, err := writer.Create(e.name) + if err != nil { + t.Fatalf("create zip entry %s: %v", e.name, err) + } + if _, err := entry.Write(e.data); err != nil { + t.Fatalf("write zip entry %s: %v", e.name, err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} + +func manifestFor(name, version string) []byte { + return []byte(`{"name":"` + name + `","version":"` + version + `","entryPoint":"plugin.wasm"}`) +} + +func TestUploadRejectsMultipleManifests(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + zipBytes := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "1.0.0")}, + {"nested/manifest.json", manifestFor("other", "9.9.9")}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + + err := m.UploadPluginWithLicense("", zipBytes, nil) + if err == nil { + t.Fatal("expected upload to fail for multiple manifests") + } + if !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("error = %v, want an exactly-one-manifest diagnostic", err) + } + assertNoPluginDirs(t, root) +} + +func TestUploadRejectsNameMismatch(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + zipBytes := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("other", "1.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + + if err := m.UploadPluginWithLicense("requested", zipBytes, nil); err == nil { + t.Fatal("expected upload to fail when the requested name disagrees with the manifest") + } + assertNoPluginDirs(t, root) +} + +func TestUploadRejectsEntryOutsideRoot(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + zipBytes := buildZip(t, []zipEntry{ + {"foo/manifest.json", manifestFor("foo", "1.0.0")}, + {"bar/plugin.wasm", minimalCapabilityModule()}, + }) + + err := m.UploadPluginWithLicense("", zipBytes, nil) + if err == nil { + t.Fatal("expected upload to fail for an entry outside the archive root") + } + if !strings.Contains(err.Error(), "outside the plugin root") { + t.Fatalf("error = %v, want an outside-root diagnostic", err) + } + assertNoPluginDirs(t, root) +} + +func TestUploadRejectsTraversalEntry(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + zipBytes := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "1.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + {"../escaped.txt", []byte("nope")}, + }) + + if err := m.UploadPluginWithLicense("", zipBytes, nil); err == nil { + t.Fatal("expected upload to fail for a traversal entry") + } + if _, err := os.Stat(filepath.Join(root, "..", "escaped.txt")); err == nil { + t.Fatal("traversal entry was written outside the plugin directory") + } + assertNoPluginDirs(t, root) +} + +func TestUploadFailurePreservesPreviousInstall(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + v1 := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "1.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + if err := m.UploadPluginWithLicense("", v1, nil); err != nil { + t.Fatalf("upload v1: %v", err) + } + + // v2 names an entry point that the archive does not contain, so it must + // fail before promotion and leave v1 intact. + v2 := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "2.0.0")}, + {"other.wasm", minimalCapabilityModule()}, + }) + if err := m.UploadPluginWithLicense("", v2, nil); err == nil { + t.Fatal("expected upload of a broken archive to fail") + } + + plugin, ok := m.GetPlugin("demo") + if !ok { + t.Fatal("plugin disappeared after a failed upgrade") + } + if plugin.Manifest.Version != "1.0.0" { + t.Fatalf("version = %q, want 1.0.0 after a failed upgrade", plugin.Manifest.Version) + } + if _, err := os.Stat(filepath.Join(root, "demo", "manifest.json")); err != nil { + t.Fatalf("previous install manifest missing: %v", err) + } +} + +func TestUploadReplacesAndRemovesStaleFiles(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + v1 := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "1.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + {"assets/extra.js", []byte("stale")}, + }) + if err := m.UploadPluginWithLicense("", v1, nil); err != nil { + t.Fatalf("upload v1: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "demo", "assets", "extra.js")); err != nil { + t.Fatalf("v1 asset missing after upload: %v", err) + } + + v2 := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "2.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + if err := m.UploadPluginWithLicense("", v2, nil); err != nil { + t.Fatalf("upload v2: %v", err) + } + + plugin, ok := m.GetPlugin("demo") + if !ok { + t.Fatal("plugin missing after upgrade") + } + if plugin.Manifest.Version != "2.0.0" { + t.Fatalf("version = %q, want 2.0.0", plugin.Manifest.Version) + } + if _, err := os.Stat(filepath.Join(root, "demo", "assets", "extra.js")); !os.IsNotExist(err) { + t.Fatalf("stale asset survived the upgrade (err = %v)", err) + } +} + +func TestUploadReplacementDropsRemovedSchedules(t *testing.T) { + root := t.TempDir() + m := NewManager(root) + t.Cleanup(func() { _ = m.Close() }) + + v1 := buildZip(t, []zipEntry{ + {"manifest.json", []byte(`{ + "name": "demo", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "schedules": [{"id": "tick", "every": "1h", "handler": "tick"}] + }`)}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + if err := m.UploadPluginWithLicense("", v1, nil); err != nil { + t.Fatalf("upload v1: %v", err) + } + future := time.Now().Add(2 * time.Hour) + if due := m.DueSchedules(future); len(due) != 1 { + t.Fatalf("v1 schedules due = %d, want 1", len(due)) + } + + v2 := buildZip(t, []zipEntry{ + {"manifest.json", manifestFor("demo", "2.0.0")}, + {"plugin.wasm", minimalCapabilityModule()}, + }) + if err := m.UploadPluginWithLicense("", v2, nil); err != nil { + t.Fatalf("upload v2: %v", err) + } + if due := m.DueSchedules(future); len(due) != 0 { + t.Fatalf("removed schedule still due = %d, want 0", len(due)) + } +} + +func TestUploadPreservesDeclaredAssetPolicy(t *testing.T) { + root := t.TempDir() + m := NewManager(root, WithAllowPublicRoutes(true)) + t.Cleanup(func() { _ = m.Close() }) + + zipBytes := buildZip(t, []zipEntry{ + {"manifest.json", []byte(`{ + "name": "demo", + "version": "1.0.0", + "entryPoint": "plugin.wasm", + "assets": [{"path": "public/*", "auth": "none"}] + }`)}, + {"plugin.wasm", minimalCapabilityModule()}, + {"assets/public/app.js", []byte("export const x = 1;")}, + {"assets/admin/app.js", []byte("export const y = 2;")}, + }) + if err := m.UploadPluginWithLicense("", zipBytes, nil); err != nil { + t.Fatalf("upload: %v", err) + } + + if _, err := os.Stat(filepath.Join(root, "demo", "assets", "public", "app.js")); err != nil { + t.Fatalf("nested public asset missing: %v", err) + } + + auth, err := m.GetAssetAuth("demo", "public/app.js") + if err != nil { + t.Fatalf("GetAssetAuth: %v", err) + } + if auth != RouteAuthNone { + t.Fatalf("public asset auth = %q, want none", auth) + } + auth, err = m.GetAssetAuth("demo", "admin/app.js") + if err != nil { + t.Fatalf("GetAssetAuth: %v", err) + } + if auth != RouteAuthSession { + t.Fatalf("undeclared asset auth = %q, want session", auth) + } +} + +func TestReadBoundedResponse(t *testing.T) { + t.Run("under limit", func(t *testing.T) { + data, err := readBoundedResponse(strings.NewReader("hello"), 16) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "hello" { + t.Fatalf("data = %q, want hello", data) + } + }) + + t.Run("exactly at limit", func(t *testing.T) { + data, err := readBoundedResponse(strings.NewReader("hello"), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "hello" { + t.Fatalf("data = %q, want hello", data) + } + }) + + t.Run("over limit returns the sentinel error", func(t *testing.T) { + _, err := readBoundedResponse(strings.NewReader("hello"), 4) + if !errors.Is(err, errHTTPFetchResponseTooLarge) { + t.Fatalf("error = %v, want errHTTPFetchResponseTooLarge", err) + } + }) +} + +// assertNoPluginDirs fails when a failed upload left a plugin or staging +// directory behind. +func assertNoPluginDirs(t *testing.T, root string) { + t.Helper() + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read plugin root: %v", err) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("plugin root not empty after a failed upload: %v", names) + } +} diff --git a/internal/ratelimit/limiter_test.go b/internal/ratelimit/limiter_test.go new file mode 100644 index 0000000..bbe6e35 --- /dev/null +++ b/internal/ratelimit/limiter_test.go @@ -0,0 +1,296 @@ +//go:build test + +package ratelimit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "windshift/internal/contextkeys" + "windshift/internal/models" + "windshift/internal/utils" +) + +func requestWithUser(id int, remote string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + r.RemoteAddr = remote + return r.WithContext(context.WithValue(r.Context(), contextkeys.User, &models.User{ID: id})) +} + +func requestWithToken(id int, remote string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/rest/api/v1/items", nil) + r.RemoteAddr = remote + return r.WithContext(context.WithValue(r.Context(), contextkeys.APIToken, &models.APIToken{ID: id})) +} + +func TestLimiterAllowsBurstThenDenies(t *testing.T) { + limiter := New(0, 2, ByUser()) + defer limiter.Stop() + + key := UserKey(1) + if !limiter.Allow(key) || !limiter.Allow(key) { + t.Fatal("burst of 2 should both be allowed") + } + if limiter.Allow(key) { + t.Fatal("third request should be denied once the burst is exhausted") + } +} + +func TestLimiterSeparatesKeys(t *testing.T) { + limiter := New(0, 1, ByUser()) + defer limiter.Stop() + + if !limiter.Allow(UserKey(1)) { + t.Fatal("first user should be allowed") + } + if limiter.Allow(UserKey(1)) { + t.Fatal("same user should be denied") + } + if !limiter.Allow(UserKey(2)) { + t.Fatal("a different user has an independent budget") + } +} + +func TestLimiterCheckReportsRemaining(t *testing.T) { + limiter := New(0, 3, ByUser()) + defer limiter.Stop() + + result := limiter.Check(UserKey(7)) + if !result.Allowed || result.Limit != 3 || result.Remaining != 2 { + t.Fatalf("first check = %+v, want allowed with 2 remaining", result) + } + result = limiter.Check(UserKey(7)) + if result.Remaining != 1 { + t.Fatalf("second check remaining = %d, want 1", result.Remaining) + } +} + +func TestLimiterMiddlewareWritesUnified429(t *testing.T) { + limiter := New(0, 1, ByIP(nil)) + defer limiter.Stop() + + handler := limiter.Limit(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + newRequest := func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + r.RemoteAddr = "198.51.100.7:4000" + return r + } + + first := httptest.NewRecorder() + handler.ServeHTTP(first, newRequest()) + if first.Code != http.StatusOK { + t.Fatalf("first request status = %d, want 200", first.Code) + } + if got := first.Header().Get("X-RateLimit-Limit"); got != "1" { + t.Fatalf("X-RateLimit-Limit = %q, want 1", got) + } + + second := httptest.NewRecorder() + handler.ServeHTTP(second, newRequest()) + if second.Code != http.StatusTooManyRequests { + t.Fatalf("second request status = %d, want 429", second.Code) + } + if second.Header().Get("Retry-After") == "" { + t.Fatal("429 missing Retry-After") + } + if got := second.Header().Get("X-RateLimit-Remaining"); got != "0" { + t.Fatalf("X-RateLimit-Remaining = %q, want 0", got) + } + var body struct { + Error string `json:"error"` + Code string `json:"code"` + } + if err := json.Unmarshal(second.Body.Bytes(), &body); err != nil { + t.Fatalf("decode 429 body: %v (%s)", err, second.Body) + } + if body.Code != "RATE_LIMITED" || body.Error == "" { + t.Fatalf("429 body = %+v, want a RATE_LIMITED error", body) + } +} + +func TestLimiterByUserSharesBudgetAcrossIPs(t *testing.T) { + limiter := New(0, 1, ByUser()) + defer limiter.Stop() + + if !limiter.AllowRequest(requestWithUser(1, "1.1.1.1:1000")) { + t.Fatal("first request for user 1 should be allowed") + } + if limiter.AllowRequest(requestWithUser(1, "2.2.2.2:2000")) { + t.Fatal("user 1's budget should be shared across client IPs") + } + if !limiter.AllowRequest(requestWithUser(2, "1.1.1.1:1000")) { + t.Fatal("user 2 has an independent budget") + } +} + +func TestLimiterByUserOrIPFallsBackToAnonymousIP(t *testing.T) { + limiter := New(0, 1, ByUserOrIP(nil)) + defer limiter.Stop() + + anon := func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/public/board", nil) + r.RemoteAddr = "203.0.113.9:3000" + return r + } + if !limiter.AllowRequest(anon()) { + t.Fatal("first anonymous request should be allowed") + } + if limiter.AllowRequest(anon()) { + t.Fatal("second anonymous request from the same IP should be denied") + } + + if !limiter.AllowRequest(requestWithUser(3, "203.0.113.9:3000")) { + t.Fatal("authenticated request should key by user, not the shared IP") + } +} + +func TestLimiterByUserDeclinesAnonymousRequests(t *testing.T) { + limiter := New(0, 1, ByUser()) + defer limiter.Stop() + + anonymous := httptest.NewRequest(http.MethodGet, "/api/items", nil) + anonymous.RemoteAddr = "198.51.100.1:1000" + if !limiter.AllowRequest(anonymous) { + t.Fatal("anonymous request on a user-keyed limiter should be unlimited") + } + if _, ok := limiter.Decide(anonymous); ok { + t.Fatal("Decide should report the key function declined the request") + } +} + +func TestLimiterByTokenKeysByToken(t *testing.T) { + limiter := New(0, 1, ByToken()) + defer limiter.Stop() + + if !limiter.AllowRequest(requestWithToken(1, "1.1.1.1:1")) { + t.Fatal("first request for token 1 should be allowed") + } + if limiter.AllowRequest(requestWithToken(1, "2.2.2.2:2")) { + t.Fatal("token 1's budget should be shared across client IPs") + } + if !limiter.AllowRequest(requestWithToken(2, "1.1.1.1:1")) { + t.Fatal("token 2 has an independent budget") + } +} + +// loginRequest builds a login request whose socket peer is a private (trusted) +// proxy and whose leading X-Forwarded-For entry is attacker-chosen. The trusted +// proxy appends the real client (203.0.113.7) on the right. +func loginRequest(spoofedXFF string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + r.RemoteAddr = "10.0.0.2:54321" + r.Header.Set("X-Forwarded-For", spoofedXFF+", 203.0.113.7") + return r +} + +func TestLimiterByIPIgnoresSpoofedForwardedForBehindTrustedProxy(t *testing.T) { + limiter := New(1, 1, ByIP(utils.NewIPExtractor(true, nil))) + defer limiter.Stop() + + if !limiter.AllowRequest(loginRequest("10.9.0.1")) { + t.Fatal("first request for the real client should be allowed") + } + if limiter.AllowRequest(loginRequest("10.9.0.2")) { + t.Fatal("rotating X-Forwarded-For bypassed the per-IP rate limit") + } +} + +func TestLimiterByIPIgnoresForwardedForWhenProxyDisabled(t *testing.T) { + limiter := New(0, 1, ByIP(utils.NewIPExtractor(false, nil))) + defer limiter.Stop() + + request := func(xff string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + r.RemoteAddr = "198.51.100.4:1000" + r.Header.Set("X-Forwarded-For", xff) + return r + } + if !limiter.AllowRequest(request("1.1.1.1")) { + t.Fatal("first request should be allowed") + } + if limiter.AllowRequest(request("2.2.2.2")) { + t.Fatal("spoofed X-Forwarded-For changed the key while proxy mode is off") + } +} + +func TestLimiterSweepEvictsIdleBuckets(t *testing.T) { + limiter := New(100, 100, ByUser()) + defer limiter.Stop() + + limiter.Allow(UserKey(1)) + limiter.Allow(UserKey(2)) + if got := limiter.totalBuckets(); got != 2 { + t.Fatalf("bucket count = %d, want 2", got) + } + + limiter.sweep(time.Now().Add(2 * limiter.idleTTL)) + + if got := limiter.totalBuckets(); got != 0 { + t.Fatalf("after sweep, bucket count = %d, want 0", got) + } +} + +func TestLimiterMaxEntriesBoundsBuckets(t *testing.T) { + limiter := New(100, 100, ByUser(), WithShards(1), WithMaxEntries(4)) + defer limiter.Stop() + + for i := 0; i < 100; i++ { + limiter.Allow(UserKey(i)) + } + if got := limiter.totalBuckets(); got > 4 { + t.Fatalf("bucket count = %d, want at most 4", got) + } +} + +func TestLimiterStopIsIdempotent(t *testing.T) { + limiter := New(10, 10, ByUser()) + limiter.Stop() + limiter.Stop() +} + +func TestLimiterAllowPathDoesNotAllocate(t *testing.T) { + limiter := New(1e9, 1e9, ByUser()) + defer limiter.Stop() + + key := UserKey(1) + limiter.Allow(key) + if allocs := testing.AllocsPerRun(1000, func() { limiter.Allow(key) }); allocs != 0 { + t.Fatalf("Allow allocated %v times per call, want 0", allocs) + } +} + +func (l *Limiter) totalBuckets() int { + total := 0 + for i := range l.shards { + l.shards[i].mu.RLock() + total += len(l.shards[i].buckets) + l.shards[i].mu.RUnlock() + } + return total +} + +func BenchmarkLimiterAllowParallel(b *testing.B) { + limiter := New(1e9, 1e9, ByUser()) + defer limiter.Stop() + + keys := make([]Key, 1024) + for i := range keys { + keys[i] = UserKey(i) + } + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + limiter.Allow(keys[i&1023]) + i++ + } + }) +} diff --git a/internal/repository/active_timer_repository_test.go b/internal/repository/active_timer_repository_test.go index 8620060..1550025 100644 --- a/internal/repository/active_timer_repository_test.go +++ b/internal/repository/active_timer_repository_test.go @@ -102,7 +102,6 @@ func newActiveTimerFixture(t *testing.T) activeTimerFixture { timerID: timerID, worklog: CreateWorklogInput{ ProjectID: projectID, - CustomerID: customerID, UserID: userID, Description: "Investigate timer", DateUnix: 0, diff --git a/internal/repository/attachment_portal_visibility_test.go b/internal/repository/attachment_portal_visibility_test.go index 03b8228..b8e0d00 100644 --- a/internal/repository/attachment_portal_visibility_test.go +++ b/internal/repository/attachment_portal_visibility_test.go @@ -9,6 +9,79 @@ import ( "windshift/internal/testutils" ) +func TestSetItemInternalTogglesPortalVisibility(t *testing.T) { + t.Parallel() + + tdb := testutils.CreateTestDB(t, true) + defer func() { _ = tdb.Close() }() + repo := NewAttachmentRepository(tdb.GetDatabase()) + + workspaceID := testutils.InsertID(t, tdb, `INSERT INTO workspaces (name, key) VALUES ('Visibility Toggle', 'VTOG')`) + itemID := testutils.InsertID(t, tdb, `INSERT INTO items (workspace_id, workspace_item_number, title, frac_index) VALUES (?, 1, 'Request', 'a0')`, workspaceID) + + publicID := testutils.InsertID(t, tdb, ` + INSERT INTO attachments (item_id, entity_type, filename, original_filename, file_path, mime_type, file_size, is_internal) + VALUES (?, 'item', 'evidence.png', 'evidence.png', '/tmp/evidence.png', 'image/png', 1, false) + `, itemID) + // A page-scoped row shares the attachments table; the items-scoped setter + // must refuse to reclassify it even though its item_id column is set. + pageID := testutils.InsertID(t, tdb, ` + INSERT INTO attachments (item_id, entity_type, filename, original_filename, file_path, mime_type, file_size, is_internal) + VALUES (?, 'page', 'diagram.json', 'diagram.json', '/tmp/diagram.json', 'application/json', 1, false) + `, itemID) + + if err := repo.SetItemInternal(publicID, true); err != nil { + t.Fatalf("SetItemInternal(item, true) error = %v", err) + } + + // The portal list and download path must now hide the attachment. + portal, total, err := repo.ListPortalItem(itemID, 50, 0) + if err != nil { + t.Fatalf("ListPortalItem() error = %v", err) + } + if total != 0 || len(portal) != 0 { + t.Fatalf("ListPortalItem() = %d/%d, want 0/0 after hiding", len(portal), total) + } + if _, err := repo.GetPortalRequestAttachmentRecord(publicID, itemID); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetPortalRequestAttachmentRecord(hidden) error = %v, want ErrNotFound", err) + } + + // Workspace members still see it, flagged as internal. + all, allTotal, err := repo.ListItem(itemID, 50, 0) + if err != nil { + t.Fatalf("ListItem() error = %v", err) + } + if allTotal != 1 || len(all) != 1 { + t.Fatalf("ListItem() = %d/%d, want 1/1", len(all), allTotal) + } + if !all[0].IsInternal { + t.Fatalf("ListItem() attachment is_internal = false, want true") + } + + // Toggling back reveals it to the portal. + if err := repo.SetItemInternal(publicID, false); err != nil { + t.Fatalf("SetItemInternal(item, false) error = %v", err) + } + portal, total, err = repo.ListPortalItem(itemID, 50, 0) + if err != nil { + t.Fatalf("ListPortalItem() after reveal error = %v", err) + } + if total != 1 || len(portal) != 1 || portal[0].ID != publicID { + t.Fatalf("ListPortalItem() after reveal = %d/%d, want 1/1 with id %d", len(portal), total, publicID) + } + if portal[0].IsInternal { + t.Fatalf("ListPortalItem() attachment is_internal = true, want false") + } + + // Page-scoped and missing rows are not reachable from the items surface. + if err := repo.SetItemInternal(pageID, true); !errors.Is(err, ErrNotFound) { + t.Fatalf("SetItemInternal(page row) error = %v, want ErrNotFound", err) + } + if err := repo.SetItemInternal(999999, true); !errors.Is(err, ErrNotFound) { + t.Fatalf("SetItemInternal(missing) error = %v, want ErrNotFound", err) + } +} + // TestPortalAttachmentVisibilityExcludesInternal is the WI-1700 persistence // contract: internal attachments stay visible to workspace members but are // hidden from the portal list and denied on the portal download path. diff --git a/internal/repository/audit_log_filters_test.go b/internal/repository/audit_log_filters_test.go new file mode 100644 index 0000000..e57d3ef --- /dev/null +++ b/internal/repository/audit_log_filters_test.go @@ -0,0 +1,88 @@ +//go:build test + +package repository + +import ( + "net/url" + "testing" + "time" +) + +// TestParseAuditLogFilters covers the canonical audit-log query contract +// shared by the legacy, v1, and v2 read surfaces (WI-1901). +func TestParseAuditLogFilters(t *testing.T) { + from := "2026-01-02T03:04:05Z" + to := "2026-01-03T03:04:05Z" + filters, err := ParseAuditLogFilters(url.Values{ + "action_type": {"user.create"}, + "resource_type": {"user"}, + "source": {"SCIM"}, + "search": {"alice"}, + "user_id": {"7"}, + "success": {"false"}, + "from": {from}, + "to": {to}, + }) + if err != nil { + t.Fatalf("ParseAuditLogFilters: %v", err) + } + if filters.ActionType != "user.create" || filters.ResourceType != "user" || filters.Source != "SCIM" || filters.Search != "alice" { + t.Fatalf("string filters = %#v", filters) + } + if filters.UserID == nil || *filters.UserID != 7 { + t.Fatalf("user_id = %#v", filters.UserID) + } + if filters.Success == nil || *filters.Success { + t.Fatalf("success = %#v", filters.Success) + } + wantFrom, _ := time.Parse(time.RFC3339, from) + wantTo, _ := time.Parse(time.RFC3339, to) + if filters.From == nil || !filters.From.Equal(wantFrom) || filters.To == nil || !filters.To.Equal(wantTo) { + t.Fatalf("time bounds = %#v .. %#v", filters.From, filters.To) + } + + details := filters.Details() + if details["action_type"] != "user.create" || details["user_id"] != 7 || details["success"] != false { + t.Fatalf("details = %#v", details) + } + if details["from"] != from || details["to"] != to { + t.Fatalf("time details = %#v", details) + } +} + +// TestParseAuditLogFiltersEmpty verifies that an empty query yields no filters. +func TestParseAuditLogFiltersEmpty(t *testing.T) { + filters, err := ParseAuditLogFilters(url.Values{}) + if err != nil { + t.Fatalf("ParseAuditLogFilters: %v", err) + } + if len(filters.Details()) != 0 { + t.Fatalf("empty query produced details: %#v", filters.Details()) + } +} + +// TestParseAuditLogFiltersRejectsMalformed verifies the shared contract +// rejects malformed values instead of silently ignoring them, which is the +// divergence WI-1901 removed. +func TestParseAuditLogFiltersRejectsMalformed(t *testing.T) { + tests := []struct { + name string + values url.Values + }{ + {"success", url.Values{"success": {"maybe"}}}, + {"user_id non-numeric", url.Values{"user_id": {"abc"}}}, + {"user_id zero", url.Values{"user_id": {"0"}}}, + {"user_id negative", url.Values{"user_id": {"-3"}}}, + {"from", url.Values{"from": {"yesterday"}}}, + {"to", url.Values{"to": {"2026-13-01T00:00:00Z"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseAuditLogFilters(tt.values); err == nil { + t.Fatalf("expected a client error for %v", tt.values) + } else if _, ok := err.(*AuditLogFilterError); !ok { + t.Fatalf("error type = %T, want *AuditLogFilterError", err) + } + }) + } +} diff --git a/internal/repository/channel_config_secret_flag_test.go b/internal/repository/channel_config_secret_flag_test.go new file mode 100644 index 0000000..40c4ded --- /dev/null +++ b/internal/repository/channel_config_secret_flag_test.go @@ -0,0 +1,57 @@ +//go:build test + +package repository + +import ( + "context" + "encoding/json" + "testing" +) + +// A stored email OAuth client secret is scrubbed from channel reads, so the +// response must instead advertise that one exists. Without this flag the UI +// cannot tell "never configured" from "saved but hidden" and blocks Connect. +func TestFindByIDReportsStoredEmailOAuthClientSecret(t *testing.T) { + db := newChannelRepositoryTestDB(t) + + encrypted := insertTestChannel(t, db, "email", "inbound", "disabled", + `{"email_oauth_client_id":"client","email_oauth_client_secret":"enc:v1:ZmFrZS1jaXBoZXJ0ZXh0"}`, false) + legacyPlaintext := insertTestChannel(t, db, "email", "inbound", "disabled", + `{"email_oauth_client_id":"client","email_oauth_client_secret":"legacy-plaintext-secret"}`, false) + missing := insertTestChannel(t, db, "email", "inbound", "disabled", + `{"email_oauth_client_id":"client"}`, false) + blank := insertTestChannel(t, db, "email", "inbound", "disabled", + `{"email_oauth_client_id":"client","email_oauth_client_secret":""}`, false) + + repo := NewChannelRepository(db) + cases := []struct { + name string + channel int + wantFlag bool + }{ + {name: "encrypted secret", channel: encrypted, wantFlag: true}, + {name: "legacy plaintext secret", channel: legacyPlaintext, wantFlag: true}, + {name: "no secret key", channel: missing, wantFlag: false}, + {name: "blank secret", channel: blank, wantFlag: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + channel, err := repo.FindByID(context.Background(), tc.channel) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if channel.HasEmailOAuthClientSecret != tc.wantFlag { + t.Fatalf("HasEmailOAuthClientSecret = %v, want %v", channel.HasEmailOAuthClientSecret, tc.wantFlag) + } + + var config map[string]any + if err := json.Unmarshal([]byte(channel.Config), &config); err != nil { + t.Fatalf("returned config is not a JSON object: %v", err) + } + if _, present := config["email_oauth_client_secret"]; present { + t.Fatalf("returned config still exposes the client secret: %s", channel.Config) + } + }) + } +} diff --git a/internal/repository/email_template_translation_test.go b/internal/repository/email_template_translation_test.go new file mode 100644 index 0000000..b128567 --- /dev/null +++ b/internal/repository/email_template_translation_test.go @@ -0,0 +1,121 @@ +//go:build test + +package repository + +import ( + "errors" + "testing" + + "windshift/internal/testutils" +) + +func newEmailTemplateRepo(t *testing.T) *EmailTemplateRepository { + t.Helper() + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + return NewEmailTemplateRepository(tdb.GetDatabase()) +} + +// TestEmailTemplateTranslationResolution covers the resolution order: +// exact-locale override, then a customized base row, then the embedded default. +func TestEmailTemplateTranslationResolution(t *testing.T) { + repo := newEmailTemplateRepo(t) + + if err := repo.SeedDefault("magic_link", "desc", "Sign in", `

Sign in

`, "Sign in"); err != nil { + t.Fatalf("SeedDefault: %v", err) + } + base, err := repo.GetByName("magic_link") + if err != nil { + t.Fatalf("GetByName: %v", err) + } + + // No override and an unmodified seed: the embedded default applies. + tmpl, fromDB, err := repo.ResolveForLocale("magic_link", "de") + if err != nil { + t.Fatalf("ResolveForLocale (no override): %v", err) + } + if tmpl != nil || fromDB { + t.Fatalf("ResolveForLocale returned (%v, %v), want embedded default", tmpl, fromDB) + } + + // Adding a locale override wins. + if _, err := repo.UpsertTranslation(base.ID, "de", "DE subject", "

DE body

", "DE text"); err != nil { + t.Fatalf("UpsertTranslation: %v", err) + } + tmpl, fromDB, err = repo.ResolveForLocale("magic_link", "de") + if err != nil || !fromDB || tmpl == nil { + t.Fatalf("ResolveForLocale (de) = (%v, %v, %v)", tmpl, fromDB, err) + } + if tmpl.Subject != "DE subject" || tmpl.HTMLBody != "

DE body

" || tmpl.TextBody != "DE text" { + t.Fatalf("override not applied: %+v", tmpl) + } + + // Region tags normalize to the shipped locale. + if tmpl, _, _ := repo.ResolveForLocale("magic_link", "de-AT"); tmpl == nil || tmpl.Subject != "DE subject" { + t.Fatalf("de-AT did not resolve to the de override: %+v", tmpl) + } + + // English still has no override and the base is unmodified. + if tmpl, fromDB, _ := repo.ResolveForLocale("magic_link", "en"); tmpl != nil || fromDB { + t.Fatalf("en resolved to a DB row, want embedded default") + } + + // A customized base row is the fallback for locales without an override. + if _, err := repo.Update(base.ID, "Custom base", "

base

", "base", "desc", true); err != nil { + t.Fatalf("Update: %v", err) + } + if tmpl, fromDB, _ := repo.ResolveForLocale("magic_link", "en"); !fromDB || tmpl == nil || tmpl.Subject != "Custom base" { + t.Fatalf("en did not fall back to the customized base: %+v", tmpl) + } + if tmpl, _, _ := repo.ResolveForLocale("magic_link", "de"); tmpl == nil || tmpl.Subject != "DE subject" { + t.Fatalf("de override lost after base update: %+v", tmpl) + } + + // Removing the override reverts to the customized base. + if err := repo.DeleteTranslation(base.ID, "de"); err != nil { + t.Fatalf("DeleteTranslation: %v", err) + } + if tmpl, fromDB, _ := repo.ResolveForLocale("magic_link", "de"); !fromDB || tmpl == nil || tmpl.Subject != "Custom base" { + t.Fatalf("de did not revert to the base row: %+v", tmpl) + } + if err := repo.DeleteTranslation(base.ID, "de"); !errors.Is(err, ErrNotFound) { + t.Fatalf("DeleteTranslation(missing) = %v, want ErrNotFound", err) + } +} + +// TestEmailTemplateListIncludesTranslations ensures the admin list carries the +// per-locale overrides so the editor can pre-fill them. +func TestEmailTemplateListIncludesTranslations(t *testing.T) { + repo := newEmailTemplateRepo(t) + if err := repo.SeedDefault("magic_link", "desc", "Sign in", "

Sign in

", "Sign in"); err != nil { + t.Fatalf("SeedDefault: %v", err) + } + base, err := repo.GetByName("magic_link") + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if _, err := repo.UpsertTranslation(base.ID, "de", "DE", "

DE

", "DE"); err != nil { + t.Fatalf("UpsertTranslation: %v", err) + } + if _, err := repo.UpsertTranslation(base.ID, "fr", "FR", "

FR

", "FR"); err != nil { + t.Fatalf("UpsertTranslation: %v", err) + } + + list, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + for _, tmpl := range list { + if tmpl.Name != "magic_link" { + continue + } + if len(tmpl.Translations) != 2 { + t.Fatalf("translations = %d, want 2", len(tmpl.Translations)) + } + if tmpl.Translations[0].Locale != "de" || tmpl.Translations[1].Locale != "fr" { + t.Fatalf("translations not ordered by locale: %+v", tmpl.Translations) + } + return + } + t.Fatal("magic_link template not found in list") +} diff --git a/internal/repository/item_count_cache_test.go b/internal/repository/item_count_cache_test.go index 42b5710..863c806 100644 --- a/internal/repository/item_count_cache_test.go +++ b/internal/repository/item_count_cache_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "fmt" "sync" "testing" "time" @@ -217,6 +218,99 @@ func TestItemFieldUpdateDoesNotInvalidateWorkspaceCount(t *testing.T) { } } +func TestItemListCountCacheSweepEvictsOverBoundEntries(t *testing.T) { + isolateItemListCountCache(t) + + now := time.Now() + itemListCountCache.Lock() + for i := 0; i < itemListCountCacheMaxEntries+256; i++ { + itemListCountCache.entries[fmt.Sprintf("workspace:%d", i)] = itemListCountCacheEntry{ + count: 1, + expiresAt: now.Add(time.Minute), + } + } + sweepItemListCountCacheLocked(now) + got := len(itemListCountCache.entries) + itemListCountCache.Unlock() + + if got > itemListCountCacheMaxEntries { + t.Fatalf("entries after sweep = %d, want <= %d", got, itemListCountCacheMaxEntries) + } +} + +func TestItemListCountCacheSweepDropsEntriesExpiredBeyondGrace(t *testing.T) { + isolateItemListCountCache(t) + + now := time.Now() + itemListCountCache.Lock() + itemListCountCache.entries["stale"] = itemListCountCacheEntry{ + count: 1, + expiresAt: now.Add(-itemListCountCacheSweepGrace - time.Second), + } + itemListCountCache.entries["fresh"] = itemListCountCacheEntry{ + count: 2, + expiresAt: now.Add(time.Minute), + } + sweepItemListCountCacheLocked(now) + _, stale := itemListCountCache.entries["stale"] + _, fresh := itemListCountCache.entries["fresh"] + itemListCountCache.Unlock() + + if stale { + t.Fatal("long-expired entry survived the sweep") + } + if !fresh { + t.Fatal("fresh entry was dropped by the sweep") + } +} + +func TestInvalidateItemListCountCacheKeepsGenerationTombstone(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + db := tdb.GetDatabase() + + if count := loadItemCount(t, db, 501, `SELECT 1`); count != 1 { + t.Fatalf("initial count = %d, want 1", count) + } + InvalidateItemListCountCache(db, 501) + + key := itemListCountCacheKey(db, 501) + itemListCountCache.Lock() + entry, ok := itemListCountCache.entries[key] + itemListCountCache.Unlock() + if !ok { + t.Fatal("invalidation dropped the generation tombstone") + } + if entry.generation == 0 { + t.Fatal("invalidation did not bump the generation") + } + if !entry.expiresAt.IsZero() { + t.Fatalf("invalidated entry should be expired, got expiresAt=%v", entry.expiresAt) + } + if count := loadItemCount(t, db, 501, `SELECT 2`); count != 2 { + t.Fatalf("post-invalidation count = %d, want fresh 2", count) + } +} + +// isolateItemListCountCache swaps in an empty cache map for the duration of a +// test and restores the previous map afterwards, so sweep tests do not disturb +// entries other tests rely on. +func isolateItemListCountCache(t *testing.T) { + t.Helper() + itemListCountCache.Lock() + savedEntries := itemListCountCache.entries + savedSweep := itemListCountCache.lastSweep + itemListCountCache.entries = make(map[string]itemListCountCacheEntry) + itemListCountCache.lastSweep = time.Time{} + itemListCountCache.Unlock() + t.Cleanup(func() { + itemListCountCache.Lock() + itemListCountCache.entries = savedEntries + itemListCountCache.lastSweep = savedSweep + itemListCountCache.Unlock() + }) +} + func waitItemCountSignal(t *testing.T, ctx context.Context, signal <-chan struct{}, operation string) { t.Helper() select { diff --git a/internal/repository/item_hierarchy_read_test.go b/internal/repository/item_hierarchy_read_test.go new file mode 100644 index 0000000..e48d5d7 --- /dev/null +++ b/internal/repository/item_hierarchy_read_test.go @@ -0,0 +1,124 @@ +//go:build test + +package repository + +import ( + "context" + "testing" + "time" + + "windshift/internal/testutils" +) + +func TestHierarchyDescendantsContextBreadthFirst(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + seed := tdb.SeedTestData(t) + + insert := func(number int, parentID *int) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, parent_id, + status_id, priority_id, frac_index, created_at, updated_at + ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?) + `, seed.WorkspaceID, number, "hier item", parentID, + seed.StatusID, seed.PriorityID, testutils.NextTestFracIndex(), + time.Now().UTC(), time.Now().UTC()) + } + + epic := insert(1, nil) + storyA := insert(2, &epic) + storyB := insert(3, &epic) + task := insert(4, &storyA) + subtask := insert(5, &task) + + repo := NewItemRepository(db) + groups, err := repo.HierarchyDescendantsContext(context.Background(), HierarchyReadParams{ + RootIDs: []int{epic}, + WorkspaceIDs: []int{seed.WorkspaceID}, + PerRootLimit: 100, + }) + if err != nil { + t.Fatalf("HierarchyDescendantsContext: %v", err) + } + if len(groups) != 1 { + t.Fatalf("groups = %d, want 1", len(groups)) + } + group := groups[0] + if group.Total != 4 { + t.Fatalf("total = %d, want 4", group.Total) + } + depths := map[int]int{} + for _, entry := range group.Items { + depths[entry.ID] = entry.Depth + } + if depths[storyA] != 1 || depths[storyB] != 1 || depths[task] != 2 || depths[subtask] != 3 { + t.Fatalf("depths = %v", depths) + } +} + +// TestHierarchyDescendantsContextHasMoreRespectsOffset guards the per-root +// pagination contract: a page that exhausts the root must report has_more +// false, so the tree stops offering an empty "load more". +func TestHierarchyDescendantsContextHasMoreRespectsOffset(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + seed := tdb.SeedTestData(t) + + insert := func(number int, parentID *int) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, parent_id, + status_id, priority_id, frac_index, created_at, updated_at + ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?) + `, seed.WorkspaceID, number, "hier item", parentID, + seed.StatusID, seed.PriorityID, testutils.NextTestFracIndex(), + time.Now().UTC(), time.Now().UTC()) + } + + root := insert(1, nil) + for i := 2; i <= 6; i++ { + insert(i, &root) + } + + repo := NewItemRepository(db) + fetch := func(offset int) HierarchyRootGroup { + t.Helper() + groups, err := repo.HierarchyDescendantsContext(context.Background(), HierarchyReadParams{ + RootIDs: []int{root}, + WorkspaceIDs: []int{seed.WorkspaceID}, + PerRootLimit: 2, + PerRootOffset: offset, + }) + if err != nil { + t.Fatalf("HierarchyDescendantsContext(offset=%d): %v", offset, err) + } + if len(groups) != 1 { + t.Fatalf("groups at offset %d = %d, want 1", offset, len(groups)) + } + return groups[0] + } + + cases := []struct { + offset int + want int + hasMore bool + }{ + {offset: 0, want: 2, hasMore: true}, + {offset: 2, want: 2, hasMore: true}, + {offset: 4, want: 1, hasMore: false}, + } + for _, tc := range cases { + group := fetch(tc.offset) + if len(group.Items) != tc.want || group.Total != 5 { + t.Fatalf("offset %d: items=%d total=%d, want items=%d total=5", tc.offset, len(group.Items), group.Total, tc.want) + } + if group.HasMore != tc.hasMore { + t.Fatalf("offset %d: has_more = %v, want %v", tc.offset, group.HasMore, tc.hasMore) + } + } +} diff --git a/internal/repository/item_key_reservation_test.go b/internal/repository/item_key_reservation_test.go index 4a82726..36dcbbc 100644 --- a/internal/repository/item_key_reservation_test.go +++ b/internal/repository/item_key_reservation_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "windshift/internal/database" + "windshift/internal/models" "windshift/internal/testutils" ) @@ -38,3 +40,34 @@ func TestNextWorkspaceItemNumberSkipsMovedKeyReservation(t *testing.T) { t.Fatalf("next item number = %d, want 51 so moved key 50 cannot be reused", next) } } + +// Re-deleting or racing two reservations for the same key must not error; the +// reservation is already retired, so the second insert is a no-op. +func TestReserveDeletedItemNumbersIsIdempotent(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + + repo := NewItemRepository(tdb.GetDatabase()) + item := &models.Item{ID: 42, WorkspaceID: data.WorkspaceID, WorkspaceItemNumber: 7} + + if err := database.WithTx(tdb.GetDatabase(), func(tx database.Tx) error { + if err := repo.ReserveDeletedItemNumbers(tx, []*models.Item{item}, 0); err != nil { + return err + } + return repo.ReserveDeletedItemNumbers(tx, []*models.Item{item}, 0) + }); err != nil { + t.Fatalf("reserve deleted item numbers twice: %v", err) + } + + var count int + if err := tdb.QueryRow(` + SELECT COUNT(*) FROM item_key_reservations + WHERE workspace_id = ? AND workspace_item_number = 7 + `, data.WorkspaceID).Scan(&count); err != nil { + t.Fatalf("count reservations: %v", err) + } + if count != 1 { + t.Fatalf("reservation rows = %d, want 1", count) + } +} diff --git a/internal/repository/item_list_context_test.go b/internal/repository/item_list_context_test.go index e4bd390..e3ad7c2 100644 --- a/internal/repository/item_list_context_test.go +++ b/internal/repository/item_list_context_test.go @@ -44,7 +44,7 @@ func TestMeasuredItemReadsHonorCancellation(t *testing.T) { { name: "backlog statuses", run: func() error { - _, err := repo.GetBacklogStatusIDsContext(ctx, 0) + _, err := repo.GetBacklogStatusIDsContext(ctx, 0, 0) return err }, }, diff --git a/internal/repository/item_list_count_bound_test.go b/internal/repository/item_list_count_bound_test.go new file mode 100644 index 0000000..4ca6ad0 --- /dev/null +++ b/internal/repository/item_list_count_bound_test.go @@ -0,0 +1,122 @@ +//go:build test + +package repository + +import ( + "context" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +// These tests guard the WI-1910 bound on filtered/collection/QL list totals. +// The count runs over the full filter join set on every request, so it is +// capped instead of aggregating the whole matching set. Results below the cap +// stay exact; results at the cap report the cap. + +// insertBulkItems inserts count items into workspaceID in one statement. The +// recursive CTE keeps the fixture cheap even when count exceeds the cap. +func insertBulkItems(t *testing.T, db database.Database, workspaceID, statusID, count int) { + t.Helper() + now := time.Now().UTC() + if _, err := db.ExecWrite(` + WITH RECURSIVE seq(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM seq WHERE n < ? + ) + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, + status_id, frac_index, created_at, updated_at + ) + SELECT ?, n, 'bulk item ' || n, '', ?, '0|' || n, ?, ? FROM seq + `, count, workspaceID, statusID, now, now); err != nil { + t.Fatalf("insert %d bulk items: %v", count, err) + } +} + +func filteredListParams(workspaceID, statusID int, limit int) ItemListParams { + return ItemListParams{ + WorkspaceIDs: []int{workspaceID}, + Filters: ItemFilters{StatusID: &statusID}, + Pagination: PaginationParams{Limit: limit}, + } +} + +func TestFilteredItemListCountStaysExactBelowBound(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + const itemCount = 25 + insertBulkItems(t, db, seed.WorkspaceID, seed.StatusID, itemCount) + + repo := NewItemRepository(db) + page, err := repo.FindAllWithDetailsPageContext( + context.Background(), + filteredListParams(seed.WorkspaceID, seed.StatusID, 10), + ) + if err != nil { + t.Fatalf("list filtered items: %v", err) + } + if page.Total != itemCount { + t.Fatalf("filtered total = %d, want exact %d below the cap", page.Total, itemCount) + } + if len(page.Items) != 10 { + t.Fatalf("filtered page items = %d, want 10", len(page.Items)) + } +} + +func TestFilteredItemListCountIsBounded(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + insertBulkItems(t, db, seed.WorkspaceID, seed.StatusID, boundedItemListCountLimit+1) + + counting := testutils.CountQueries(db) + repo := NewItemRepository(counting) + page, err := repo.FindAllWithDetailsPageContext( + context.Background(), + filteredListParams(seed.WorkspaceID, seed.StatusID, 10), + ) + if err != nil { + t.Fatalf("list filtered items: %v", err) + } + if page.Total != boundedItemListCountLimit { + t.Fatalf("filtered total = %d, want capped %d", page.Total, boundedItemListCountLimit) + } + if len(page.Items) != 10 { + t.Fatalf("filtered page items = %d, want 10", len(page.Items)) + } + // The bound is only useful if the count query carries it. A missing LIMIT + // means the aggregate ran over every matching row. + if got := counting.CountQueriesContaining("bounded_item_count"); got != 1 { + t.Fatalf("bounded count queries = %d, want 1", got) + } +} + +func TestFilteredItemListCountBoundDoesNotAffectPageSelection(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + insertBulkItems(t, db, seed.WorkspaceID, seed.StatusID, boundedItemListCountLimit+5) + + repo := NewItemRepository(db) + params := filteredListParams(seed.WorkspaceID, seed.StatusID, 5) + params.Pagination.Offset = boundedItemListCountLimit + + page, err := repo.FindAllWithDetailsPageContext(context.Background(), params) + if err != nil { + t.Fatalf("list deep filtered page: %v", err) + } + if len(page.Items) != 5 { + t.Fatalf("deep filtered page items = %d, want 5 beyond the count cap", len(page.Items)) + } +} diff --git a/internal/repository/item_search_bench_test.go b/internal/repository/item_search_bench_test.go new file mode 100644 index 0000000..48d736c --- /dev/null +++ b/internal/repository/item_search_bench_test.go @@ -0,0 +1,74 @@ +//go:build test + +package repository + +import ( + "context" + "fmt" + "testing" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +// BenchmarkItemTextSearch measures the indexed token-prefix search over a +// 100k-row items table. Run with: +// +// ../core-tests/overlay.sh . -- -tags=test -run '^$' -bench BenchmarkItemTextSearch -benchtime 5x ./internal/repository/... +func BenchmarkItemTextSearch(b *testing.B) { + db := newItemListTestDB(b, "item-search-bench") + workspaceID := createItemSearchBenchWorkspace(b, db) + seedItemSearchBenchItems(b, db, workspaceID, 100_000) + + repo := NewItemRepository(db) + params := ItemListParams{ + WorkspaceIDs: []int{workspaceID}, + Filters: ItemFilters{TextQuery: "outlo versio"}, + Pagination: PaginationParams{Limit: 20}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, _, err := repo.FindAllWithDetailsContext(context.Background(), params); err != nil { + b.Fatalf("search: %v", err) + } + } +} + +func createItemSearchBenchWorkspace(b *testing.B, db database.Database) int { + b.Helper() + result, err := db.ExecWrite(`INSERT INTO workspaces (name, key, description, active) VALUES ('Item search bench', 'ISBENCH', '', true)`) + if err != nil { + b.Fatalf("insert workspace: %v", err) + } + id, err := result.LastInsertId() + if err != nil { + b.Fatalf("workspace id: %v", err) + } + return int(id) +} + +func seedItemSearchBenchItems(b *testing.B, db database.Database, workspaceID, count int) { + b.Helper() + tx, err := db.Begin() + if err != nil { + b.Fatalf("begin seed transaction: %v", err) + } + defer func() { _ = tx.Rollback() }() + stmt, err := tx.Prepare(`INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, ?, ?, ?, ?)`) + if err != nil { + b.Fatalf("prepare seed insert: %v", err) + } + defer func() { _ = stmt.Close() }() + for i := range count { + description := "the quick brown fox jumps over the lazy dog" + if i%1000 == 0 { + description = "we changed the outlook version for this item" + } + if _, err := stmt.Exec(workspaceID, i+1, fmt.Sprintf("Item %d routine work", i), description, testutils.NextTestFracIndex()); err != nil { + b.Fatalf("insert item %d: %v", i, err) + } + } + if err := tx.Commit(); err != nil { + b.Fatalf("commit seed transaction: %v", err) + } +} diff --git a/internal/repository/item_search_prefix_test.go b/internal/repository/item_search_prefix_test.go new file mode 100644 index 0000000..8cbf56e --- /dev/null +++ b/internal/repository/item_search_prefix_test.go @@ -0,0 +1,166 @@ +//go:build test + +package repository + +import ( + "slices" + "testing" + + "windshift/internal/testutils" +) + +// TestItemTextSearchUsesTokenPrefixes covers the WI-413 indexed search +// contract: a query matches whole words by prefix across title and +// description, every term must match, and the FTS index stays in sync with +// items. Count and page results come from the same predicate, so Total must +// equal the number of returned IDs. +func TestItemTextSearchUsesTokenPrefixes(t *testing.T) { + db := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = db.Close() }) + + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key, active, is_personal) VALUES ('Prefix search', 'PREFIX', true, false)`) + titleItemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 1, 'We changed the Outlook version', '', ?)`, workspaceID, testutils.NextTestFracIndex()) + descriptionItemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 2, 'Unrelated title', 'The Acrobat reader crashes on start', ?)`, workspaceID, testutils.NextTestFracIndex()) + otherItemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 3, 'Billing question', 'nothing relevant', ?)`, workspaceID, testutils.NextTestFracIndex()) + + search := func(query string) ([]int, int) { + t.Helper() + items, total, err := NewItemRepository(db).FindAllWithDetailsContext(t.Context(), ItemListParams{ + WorkspaceIDs: []int{workspaceID}, + Filters: ItemFilters{TextQuery: query}, + Pagination: PaginationParams{Limit: 50}, + }) + if err != nil { + t.Fatalf("search %q: %v", query, err) + } + got := make([]int, 0, len(items)) + for _, item := range items { + got = append(got, item.ID) + } + slices.Sort(got) + return got, total + } + + for _, tt := range []struct { + name string + query string + want []int + }{ + {name: "prefix matches title", query: "Outlo", want: []int{titleItemID}}, + {name: "prefix is case insensitive", query: "outlo", want: []int{titleItemID}}, + {name: "prefix matches description", query: "Acro", want: []int{descriptionItemID}}, + {name: "all terms must match", query: "outlo versio", want: []int{titleItemID}}, + {name: "one unmatched term excludes", query: "outlo zebra"}, + {name: "mid word does not match", query: "tlook"}, + {name: "unrelated query matches nothing", query: "zzzz"}, + } { + t.Run(tt.name, func(t *testing.T) { + got, total := search(tt.query) + want := slices.Clone(tt.want) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("search %q IDs = %v, want %v", tt.query, got, want) + } + if total != len(want) { + t.Fatalf("search %q Total = %d, want %d", tt.query, total, len(want)) + } + }) + } + + // The index follows title updates: the new word matches and the old word + // no longer does. + if _, err := db.ExecWrite(`UPDATE items SET title = 'Printer queue stuck' WHERE id = ?`, titleItemID); err != nil { + t.Fatalf("update title: %v", err) + } + if got, _ := search("printer"); !slices.Equal(got, []int{titleItemID}) { + t.Fatalf("search after title update = %v, want [%d]", got, titleItemID) + } + if got, _ := search("outlook"); len(got) != 0 { + t.Fatalf("search for old title after update = %v, want none", got) + } + // Updating a non-indexed column must not disturb the index. + if _, err := db.ExecWrite(`UPDATE items SET last_active_at = CURRENT_TIMESTAMP WHERE id = ?`, titleItemID); err != nil { + t.Fatalf("update last_active_at: %v", err) + } + if got, _ := search("printer"); !slices.Equal(got, []int{titleItemID}) { + t.Fatalf("search after unrelated update = %v, want [%d]", got, titleItemID) + } + // Deleting a row removes it from the index. + if _, err := db.ExecWrite(`DELETE FROM items WHERE id = ?`, otherItemID); err != nil { + t.Fatalf("delete: %v", err) + } + if got, _ := search("billing"); len(got) != 0 { + t.Fatalf("search after delete = %v, want none", got) + } +} + +// TestItemTextSearchFallsBackToLike covers queries the FTS path cannot serve: +// terms shorter than the minimum prefix and scripts neither engine segments. +// They must still match through the original LIKE scan instead of returning +// nothing. +func TestItemTextSearchFallsBackToLike(t *testing.T) { + db := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = db.Close() }) + + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key, active, is_personal) VALUES ('Fallback search', 'FALLBK', true, false)`) + shortItemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 1, 'Zebra crossing', '', ?)`, workspaceID, testutils.NextTestFracIndex()) + cjkItemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 2, 'Mixed script', '我äģŦ攚变äē†Outlookį‰ˆæœŦ', ?)`, workspaceID, testutils.NextTestFracIndex()) + + search := func(query string) []int { + t.Helper() + items, _, err := NewItemRepository(db).FindAllWithDetailsContext(t.Context(), ItemListParams{ + WorkspaceIDs: []int{workspaceID}, + Filters: ItemFilters{TextQuery: query}, + Pagination: PaginationParams{Limit: 50}, + }) + if err != nil { + t.Fatalf("search %q: %v", query, err) + } + got := make([]int, 0, len(items)) + for _, item := range items { + got = append(got, item.ID) + } + slices.Sort(got) + return got + } + + if got := search("z"); !slices.Equal(got, []int{shortItemID}) { + t.Fatalf("one character search = %v, want [%d]", got, shortItemID) + } + if got := search("į‰ˆæœŦ"); !slices.Equal(got, []int{cjkItemID}) { + t.Fatalf("cjk search = %v, want [%d]", got, cjkItemID) + } +} + +// TestItemTextSearchFoldsAccents verifies accented words match an unaccented +// query. SQLite's unicode61 tokenizer folds accents; PostgreSQL folds them only +// when the unaccent extension is installable, so the test skips when it is not. +func TestItemTextSearchFoldsAccents(t *testing.T) { + db := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = db.Close() }) + + if db.Engine == "postgres" { + var installed int + if err := db.QueryRow(`SELECT COUNT(*) FROM pg_extension WHERE extname = 'unaccent'`).Scan(&installed); err != nil { + t.Fatalf("check unaccent: %v", err) + } + if installed == 0 { + t.Skip("unaccent extension is not installed") + } + } + + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key, active, is_personal) VALUES ('Accent search', 'ACCENT', true, false)`) + itemID := testutils.InsertID(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, description, frac_index) VALUES (?, 1, 'MÃŧller hat geantwortet', '', ?)`, workspaceID, testutils.NextTestFracIndex()) + + items, _, err := NewItemRepository(db).FindAllWithDetailsContext(t.Context(), ItemListParams{ + WorkspaceIDs: []int{workspaceID}, + Filters: ItemFilters{TextQuery: "muller"}, + Pagination: PaginationParams{Limit: 50}, + }) + if err != nil { + t.Fatalf("accent search: %v", err) + } + if len(items) != 1 || items[0].ID != itemID { + t.Fatalf("accent search returned %d items, want item %d", len(items), itemID) + } +} diff --git a/internal/repository/n_plus_one_batch_test.go b/internal/repository/n_plus_one_batch_test.go new file mode 100644 index 0000000..eaa7e3f --- /dev/null +++ b/internal/repository/n_plus_one_batch_test.go @@ -0,0 +1,397 @@ +//go:build test + +package repository + +import ( + "context" + "strings" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +// These tests guard the WI-1909 N+1 removals: each batched read must issue one +// query no matter how many rows it is given, and must still return the same +// grouping the per-row loops produced. + +func insertBatchTestItem(t *testing.T, db database.Database, seed testutils.TestDataSet, workspaceID, number int, parentID *int) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, parent_id, + status_id, priority_id, frac_index, created_at, updated_at + ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?) + `, workspaceID, number, "n+1 fixture", parentID, + seed.StatusID, seed.PriorityID, testutils.NextTestFracIndex(), + time.Now().UTC(), time.Now().UTC()) +} + +func TestGetWorkspaceIDsResolvesAllItemsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + otherWorkspace := testutils.InsertID(t, db, + `INSERT INTO workspaces (name, key, description, active) VALUES (?, ?, '', true)`, + "Other Workspace", "OTH") + + const itemCount = 10 + itemIDs := make([]int, 0, itemCount) + wantWorkspace := make(map[int]int, itemCount) + for i := 0; i < itemCount; i++ { + workspaceID := seed.WorkspaceID + if i%2 == 1 { + workspaceID = otherWorkspace + } + id := insertBatchTestItem(t, db, seed, workspaceID, i+1, nil) + itemIDs = append(itemIDs, id) + wantWorkspace[id] = workspaceID + } + + counting := testutils.CountQueries(db) + repo := NewItemRepository(counting) + + resolved, err := repo.GetWorkspaceIDs(itemIDs) + if err != nil { + t.Fatalf("GetWorkspaceIDs: %v", err) + } + if len(resolved) != itemCount { + t.Fatalf("resolved %d items, want %d", len(resolved), itemCount) + } + for id, want := range wantWorkspace { + if resolved[id] != want { + t.Errorf("item %d workspace = %d, want %d", id, resolved[id], want) + } + } + if got := counting.Queries(); got != 1 { + t.Fatalf("GetWorkspaceIDs issued %d queries for %d items, want 1", got, itemCount) + } +} + +func TestGetWorkspaceIDsOmitsUnknownItems(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + known := insertBatchTestItem(t, db, seed, seed.WorkspaceID, 1, nil) + + repo := NewItemRepository(db) + resolved, err := repo.GetWorkspaceIDs([]int{known, 999999}) + if err != nil { + t.Fatalf("GetWorkspaceIDs: %v", err) + } + if len(resolved) != 1 || resolved[known] != seed.WorkspaceID { + t.Fatalf("resolved = %v, want only item %d in workspace %d", resolved, known, seed.WorkspaceID) + } + if _, ok := resolved[999999]; ok { + t.Fatalf("unknown item must be absent from the map, got %v", resolved) + } +} + +func TestGetWorkspaceIDsEmptyInputIssuesNoQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + tdb.SeedTestData(t) + + counting := testutils.CountQueries(tdb.GetDatabase()) + repo := NewItemRepository(counting) + + resolved, err := repo.GetWorkspaceIDs(nil) + if err != nil { + t.Fatalf("GetWorkspaceIDs: %v", err) + } + if len(resolved) != 0 { + t.Fatalf("resolved = %v, want empty", resolved) + } + if got := counting.Queries(); got != 0 { + t.Fatalf("empty input issued %d queries, want 0", got) + } +} + +func TestCountByFieldInGroupsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + otherWorkspace := testutils.InsertID(t, db, + `INSERT INTO workspaces (name, key, description, active) VALUES (?, ?, '', true)`, + "Other Workspace", "OTH") + + for i := 0; i < 3; i++ { + insertBatchTestItem(t, db, seed, seed.WorkspaceID, i+1, nil) + } + for i := 0; i < 2; i++ { + insertBatchTestItem(t, db, seed, otherWorkspace, i+10, nil) + } + + counting := testutils.CountQueries(db) + repo := NewItemRepository(counting) + + counts, err := repo.CountByFieldIn("workspace_id", []int{seed.WorkspaceID, otherWorkspace, 999999}) + if err != nil { + t.Fatalf("CountByFieldIn: %v", err) + } + if counts[seed.WorkspaceID] != 3 { + t.Errorf("workspace %d count = %d, want 3", seed.WorkspaceID, counts[seed.WorkspaceID]) + } + if counts[otherWorkspace] != 2 { + t.Errorf("workspace %d count = %d, want 2", otherWorkspace, counts[otherWorkspace]) + } + if _, ok := counts[999999]; ok { + t.Errorf("workspace with no items must be absent, got %v", counts) + } + if got := counting.Queries(); got != 1 { + t.Fatalf("CountByFieldIn issued %d queries, want 1", got) + } +} + +func TestCountByFieldInRejectsNonAllowlistedColumn(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + tdb.SeedTestData(t) + + repo := NewItemRepository(tdb.GetDatabase()) + if _, err := repo.CountByFieldIn("title", []int{1}); err == nil { + t.Fatal("CountByFieldIn must reject a column outside the allow-list") + } +} + +func TestFindRootIDForItemContextWalksInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + // Build a chain deeper than the old per-level walk would have been cheap + // for: every level used to cost one query. + const depth = 20 + var parentID *int + rootID := 0 + deepestID := 0 + for i := 0; i < depth; i++ { + id := insertBatchTestItem(t, db, seed, seed.WorkspaceID, i+1, parentID) + if i == 0 { + rootID = id + } + deepestID = id + next := id + parentID = &next + } + + counting := testutils.CountQueries(db) + repo := NewItemRepository(counting) + + got, err := repo.FindRootIDForItemContext(context.Background(), deepestID, 30) + if err != nil { + t.Fatalf("FindRootIDForItemContext: %v", err) + } + if got == nil || *got != rootID { + t.Fatalf("root = %v, want %d", got, rootID) + } + if queries := counting.Queries(); queries != 1 { + t.Fatalf("walk of depth %d issued %d queries, want 1", depth, queries) + } +} + +func TestFindRootIDForItemContextReportsMissingItem(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + tdb.SeedTestData(t) + + repo := NewItemRepository(tdb.GetDatabase()) + got, err := repo.FindRootIDForItemContext(context.Background(), 999999, 30) + if err != nil { + t.Fatalf("FindRootIDForItemContext: %v", err) + } + if got != nil { + t.Fatalf("root = %d, want nil for a non-existent item", *got) + } +} + +func TestFindRootIDForItemContextDetectsCycle(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + first := insertBatchTestItem(t, db, seed, seed.WorkspaceID, 1, nil) + second := insertBatchTestItem(t, db, seed, seed.WorkspaceID, 2, &first) + third := insertBatchTestItem(t, db, seed, seed.WorkspaceID, 3, &second) + // Close the loop: first -> third -> second -> first. + if _, err := db.Exec(`UPDATE items SET parent_id = ? WHERE id = ?`, third, first); err != nil { + t.Fatalf("inject cycle: %v", err) + } + + repo := NewItemRepository(db) + _, err := repo.FindRootIDForItemContext(context.Background(), third, 30) + if err == nil { + t.Fatal("a stored cycle must surface an error, not a silent root") + } + if !strings.Contains(err.Error(), "cyclic") { + t.Fatalf("error should mention cyclic, got: %v", err) + } +} + +func TestFindStepResultsForExamplesGroupsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + setID := testutils.InsertID(t, db, + `INSERT INTO test_sets (workspace_id, name, description) VALUES (?, ?, '')`, + seed.WorkspaceID, "n+1 set") + testCaseID := testutils.InsertID(t, db, + `INSERT INTO test_cases (workspace_id, title, format, priority) VALUES (?, ?, 'bdd', 'medium')`, + seed.WorkspaceID, "n+1 case") + runID := testutils.InsertID(t, db, + `INSERT INTO test_runs (workspace_id, set_id, name) VALUES (?, ?, ?)`, + seed.WorkspaceID, setID, "n+1 run") + + const exampleCount = 5 + resultIDs := make([]int, 0, exampleCount) + wantSteps := make(map[int][]int, exampleCount) + for i := 0; i < exampleCount; i++ { + resultID := testutils.InsertID(t, db, ` + INSERT INTO test_example_results (run_id, test_case_id, example_index, row_values, status) + VALUES (?, ?, ?, '{}', 'not_run') + `, runID, testCaseID, i) + resultIDs = append(resultIDs, resultID) + // Give each example a different number of steps so grouping errors + // surface as mismatched lengths rather than passing by coincidence. + stepCount := i + 1 + for step := 1; step <= stepCount; step++ { + testutils.InsertID(t, db, ` + INSERT INTO test_example_step_results (example_result_id, step_number, status, actual_result, notes) + VALUES (?, ?, 'passed', ?, '') + `, resultID, step, "output") + wantSteps[resultID] = append(wantSteps[resultID], step) + } + } + + counting := testutils.CountQueries(db) + repo := NewTestCaseBDDRepository(counting) + + grouped, err := repo.FindStepResultsForExamples(resultIDs) + if err != nil { + t.Fatalf("FindStepResultsForExamples: %v", err) + } + if len(grouped) != exampleCount { + t.Fatalf("grouped %d examples, want %d", len(grouped), exampleCount) + } + for resultID, want := range wantSteps { + got := grouped[resultID] + if len(got) != len(want) { + t.Fatalf("example %d has %d steps, want %d", resultID, len(got), len(want)) + } + for i, step := range got { + if step.StepNumber != want[i] { + t.Errorf("example %d step %d number = %d, want %d", resultID, i, step.StepNumber, want[i]) + } + if step.ExampleResultID != resultID { + t.Errorf("step %d attributed to example %d, want %d", step.StepNumber, step.ExampleResultID, resultID) + } + } + } + if got := counting.Queries(); got != 1 { + t.Fatalf("FindStepResultsForExamples issued %d queries for %d examples, want 1", got, exampleCount) + } +} + +func TestFindStepResultsForExamplesReturnsEmptySliceForSteplessExample(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + setID := testutils.InsertID(t, db, + `INSERT INTO test_sets (workspace_id, name, description) VALUES (?, ?, '')`, + seed.WorkspaceID, "n+1 set") + testCaseID := testutils.InsertID(t, db, + `INSERT INTO test_cases (workspace_id, title, format, priority) VALUES (?, ?, 'bdd', 'medium')`, + seed.WorkspaceID, "n+1 case") + runID := testutils.InsertID(t, db, + `INSERT INTO test_runs (workspace_id, set_id, name) VALUES (?, ?, ?)`, + seed.WorkspaceID, setID, "n+1 run") + resultID := testutils.InsertID(t, db, ` + INSERT INTO test_example_results (run_id, test_case_id, example_index, row_values, status) + VALUES (?, ?, 0, '{}', 'not_run') + `, runID, testCaseID) + + repo := NewTestCaseBDDRepository(db) + grouped, err := repo.FindStepResultsForExamples([]int{resultID}) + if err != nil { + t.Fatalf("FindStepResultsForExamples: %v", err) + } + steps, ok := grouped[resultID] + if !ok { + t.Fatalf("example %d missing from grouped result", resultID) + } + // The per-example loop assigned an empty (non-nil) slice; keep that so the + // JSON contract stays [] rather than null. + if steps == nil { + t.Fatalf("steps for a stepless example must be an empty slice, got nil") + } + if len(steps) != 0 { + t.Fatalf("steps = %d, want 0", len(steps)) + } +} + +func TestFindAssetTypeFieldsForTypesGroupsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + tdb.SeedTestData(t) + db := tdb.GetDatabase() + + setID := testutils.InsertID(t, db, + `INSERT INTO asset_management_sets (name) VALUES (?)`, "n+1 asset set") + + const typeCount = 4 + typeIDs := make([]int, 0, typeCount) + wantFields := make(map[int]int, typeCount) + for i := 0; i < typeCount; i++ { + typeID := testutils.InsertID(t, db, + `INSERT INTO asset_types (set_id, name) VALUES (?, ?)`, setID, "Type "+string(rune('A'+i))) + typeIDs = append(typeIDs, typeID) + fieldCount := i + 1 + for f := 0; f < fieldCount; f++ { + fieldID := testutils.InsertID(t, db, + `INSERT INTO custom_field_definitions (name, field_type) VALUES (?, 'text')`, + "Field") + testutils.InsertID(t, db, ` + INSERT INTO asset_type_fields (asset_type_id, custom_field_id, is_required, display_order) + VALUES (?, ?, false, ?) + `, typeID, fieldID, f) + } + wantFields[typeID] = fieldCount + } + + counting := testutils.CountQueries(db) + repo := NewAssetRepository(counting) + + grouped, err := repo.FindAssetTypeFieldsForTypes(typeIDs) + if err != nil { + t.Fatalf("FindAssetTypeFieldsForTypes: %v", err) + } + if len(grouped) != typeCount { + t.Fatalf("grouped %d types, want %d", len(grouped), typeCount) + } + for typeID, want := range wantFields { + if got := len(grouped[typeID]); got != want { + t.Fatalf("type %d has %d fields, want %d", typeID, got, want) + } + for _, field := range grouped[typeID] { + if field.AssetTypeID != typeID { + t.Errorf("field %d attributed to type %d, want %d", field.ID, field.AssetTypeID, typeID) + } + } + } + if got := counting.Queries(); got != 1 { + t.Fatalf("FindAssetTypeFieldsForTypes issued %d queries for %d types, want 1", got, typeCount) + } +} diff --git a/internal/repository/portal_customer_language_test.go b/internal/repository/portal_customer_language_test.go new file mode 100644 index 0000000..50147d4 --- /dev/null +++ b/internal/repository/portal_customer_language_test.go @@ -0,0 +1,101 @@ +//go:build test + +package repository + +import ( + "context" + "errors" + "testing" + + "windshift/internal/testutils" +) + +// TestPortalCustomerLanguagePreferences covers the preference lifecycle: +// first-contact detection writes once, a chosen value is never overwritten by +// later detection, explicit updates win, and lookups are case-insensitive. +func TestPortalCustomerLanguagePreferences(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + ctx := context.Background() + + customerID := testutils.InsertID(t, db, + `INSERT INTO portal_customers (name, email) VALUES (?, ?)`, + "Alex", "alex@example.com", + ) + + repo := NewPortalCustomerRepository(db) + + // First-contact detection stores the language. + written, err := repo.SetLanguageIfUnset(customerID, "de") + if err != nil { + t.Fatalf("SetLanguageIfUnset: %v", err) + } + if !written { + t.Fatal("first detection did not write the language") + } + if got, _ := repo.Language(ctx, customerID); got != "de" { + t.Fatalf("Language = %q, want de", got) + } + + // A later login must not overwrite a value that is already set. + written, err = repo.SetLanguageIfUnset(customerID, "fr") + if err != nil { + t.Fatalf("SetLanguageIfUnset (second): %v", err) + } + if written { + t.Fatal("second detection overwrote an existing language") + } + if got, _ := repo.Language(ctx, customerID); got != "de" { + t.Fatalf("Language after second detection = %q, want de", got) + } + + // The explicit toggle always wins. + if err := repo.UpdateRegionalSettings(customerID, "es", "Europe/Berlin"); err != nil { + t.Fatalf("UpdateRegionalSettings: %v", err) + } + if got, _ := repo.Language(ctx, customerID); got != "es" { + t.Fatalf("Language after update = %q, want es", got) + } + + // Lookup by email is case-insensitive. + if got, err := repo.LanguageByEmail(ctx, "ALEX@example.com"); err != nil || got != "es" { + t.Fatalf("LanguageByEmail = (%q, %v), want (es, nil)", got, err) + } + if got, err := repo.LanguageByEmail(ctx, "nobody@example.com"); err != nil || got != "" { + t.Fatalf("LanguageByEmail(unknown) = (%q, %v), want (\"\", nil)", got, err) + } + + // Missing rows surface as ErrNotFound for id-based reads/writes. + if _, err := repo.Language(ctx, 999999); !errors.Is(err, ErrNotFound) { + t.Fatalf("Language(missing) error = %v, want ErrNotFound", err) + } + if err := repo.UpdateRegionalSettings(999999, "de", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("UpdateRegionalSettings(missing) error = %v, want ErrNotFound", err) + } +} + +// TestPortalCustomerSetLanguageIfUnsetEmptyIsNoop ensures an unmatched browser +// header (empty locale) never clears or writes the column. +func TestPortalCustomerSetLanguageIfUnsetEmptyIsNoop(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + + customerID := testutils.InsertID(t, db, + `INSERT INTO portal_customers (name, email) VALUES (?, ?)`, + "Alex", "alex@example.com", + ) + + repo := NewPortalCustomerRepository(db) + written, err := repo.SetLanguageIfUnset(customerID, "") + if err != nil { + t.Fatalf("SetLanguageIfUnset(empty): %v", err) + } + if written { + t.Fatal("empty locale was written") + } + if got, _ := repo.Language(context.Background(), customerID); got != "" { + t.Fatalf("Language = %q, want empty", got) + } +} diff --git a/internal/repository/time_project_repository_test.go b/internal/repository/time_project_repository_test.go index 68b3b93..9e633db 100644 --- a/internal/repository/time_project_repository_test.go +++ b/internal/repository/time_project_repository_test.go @@ -46,9 +46,9 @@ func TestTimeProjectRepositoryCookieAuthLifecycleAndFilters(t *testing.T) { } insertTestRow(t, db, ` - INSERT INTO time_worklogs (project_id, customer_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) - VALUES (?, ?, 'Migration work', 100, 100, 3700, 60, 100, 100) - `, project.ID, customerID) + INSERT INTO time_worklogs (project_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) + VALUES (?, 'Migration work', 100, 100, 3700, 60, 100, 100) + `, project.ID) detail, err := repo.GetDetail(project.ID) if err != nil { diff --git a/internal/repository/time_worklog_repository_test.go b/internal/repository/time_worklog_repository_test.go index 93c5969..90755bf 100644 --- a/internal/repository/time_worklog_repository_test.go +++ b/internal/repository/time_worklog_repository_test.go @@ -41,7 +41,6 @@ func TestTimeWorklogRepositoryCookieAuthDetailsLifecycle(t *testing.T) { repo := NewTimeWorklogRepository(db) worklogID, err := repo.Create(NewWorklog{ ProjectID: projectID, - CustomerID: int64(customerID), UserID: userID, ItemID: &itemID, Description: "Initial description", @@ -67,6 +66,9 @@ func TestTimeWorklogRepositoryCookieAuthDetailsLifecycle(t *testing.T) { if detail.ProjectMaxHours == nil || *detail.ProjectMaxHours != 20 || detail.ProjectTotalHours == nil || *detail.ProjectTotalHours != 1 { t.Fatalf("project totals = max %v, total %v", detail.ProjectMaxHours, detail.ProjectTotalHours) } + if detail.CustomerID != customerID || detail.CustomerName != "Worklog customer" { + t.Fatalf("derived customer = id %d, name %q, want %d/Worklog customer", detail.CustomerID, detail.CustomerName, customerID) + } listed, err := repo.ListDetails(WorklogDetailFilter{ AccessibleProjectIDs: []int{projectID}, @@ -81,7 +83,7 @@ func TestTimeWorklogRepositoryCookieAuthDetailsLifecycle(t *testing.T) { } secondID, err := repo.Create(NewWorklog{ - ProjectID: projectID, CustomerID: int64(customerID), UserID: userID, + ProjectID: projectID, UserID: userID, Description: "Exclusive boundary", DateUnix: 2000, StartTimeUnix: 2100, EndTimeUnix: 5700, DurationMinutes: 60, }) @@ -121,7 +123,6 @@ func TestTimeWorklogRepositoryCookieAuthDetailsLifecycle(t *testing.T) { if err := repo.Update(UpdateWorklog{ ID: int(worklogID), ProjectID: projectID, - CustomerID: customerID, ItemID: &itemID, Description: "Updated description", DateUnix: 2000, @@ -146,3 +147,64 @@ func TestTimeWorklogRepositoryCookieAuthDetailsLifecycle(t *testing.T) { t.Fatalf("GetDetail after delete error = %v, want ErrNotFound", err) } } + +// TestTimeWorklogSurvivesCustomerOrganisationDelete reproduces GitHub #403: a +// worklog must not be deleted when its project is unlinked from an organisation +// and that organisation is then deleted. Worklogs derive their customer from +// the project, so the organisation is no longer a worklog parent. +func TestTimeWorklogSurvivesCustomerOrganisationDelete(t *testing.T) { + db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "worklog-org-delete.db")) + if err != nil { + t.Fatalf("NewSQLiteDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + + userID := insertTestRow(t, db, ` + INSERT INTO users (email, username, first_name, last_name) + VALUES ('org-delete@example.test', 'org-delete', 'Org', 'Delete') + `) + orgID := insertTestRow(t, db, ` + INSERT INTO customer_organisations (name) VALUES ('Doomed organisation') + `) + projectID := insertTestRow(t, db, ` + INSERT INTO time_projects (customer_id, name, status) + VALUES (?, 'Unlinked project', 'Active') + `, orgID) + var noCustomerID int + if err := db.QueryRow(`SELECT id FROM customer_organisations WHERE builtin_key = 'no_customer'`).Scan(&noCustomerID); err != nil { + t.Fatalf("load no-customer organisation: %v", err) + } + + repo := NewTimeWorklogRepository(db) + worklogID, err := repo.Create(NewWorklog{ + ProjectID: projectID, + UserID: userID, + Description: "Billable time", + DateUnix: 1000, + StartTimeUnix: 1100, + EndTimeUnix: 4700, + DurationMinutes: 60, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Unlink the project from the organisation, then delete the organisation. + if _, err := db.ExecWrite("UPDATE time_projects SET customer_id = ? WHERE id = ?", noCustomerID, projectID); err != nil { + t.Fatalf("unlink project: %v", err) + } + if _, err := db.ExecWrite("DELETE FROM customer_organisations WHERE id = ?", orgID); err != nil { + t.Fatalf("delete organisation: %v", err) + } + + detail, err := repo.GetDetail(int(worklogID)) + if err != nil { + t.Fatalf("GetDetail after organisation delete: %v", err) + } + if detail.CustomerID != noCustomerID { + t.Fatalf("derived customer = %d, want no-customer %d", detail.CustomerID, noCustomerID) + } +} diff --git a/internal/restapi/v1/handlers/list_types_batch_test.go b/internal/restapi/v1/handlers/list_types_batch_test.go new file mode 100644 index 0000000..698fe63 --- /dev/null +++ b/internal/restapi/v1/handlers/list_types_batch_test.go @@ -0,0 +1,105 @@ +//go:build test + +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/restapi" + "windshift/internal/restapi/v1/dto" + "windshift/internal/services" + "windshift/internal/testutils" +) + +// WI-1909: ListTypes used to load each asset type's fields with its own query. +// It must now resolve every type's fields in one batched query. + +func grantSystemAdminForListTypesTest(t *testing.T, db database.Database, userID int) { + t.Helper() + if _, err := db.Exec(` + INSERT INTO user_global_permissions (user_id, permission_id, granted_by, granted_at) + VALUES (?, (SELECT id FROM permissions WHERE permission_key = 'system.admin'), ?, ?) + `, userID, userID, time.Now().UTC()); err != nil { + t.Fatalf("grant system admin: %v", err) + } +} + +func TestListTypesLoadsFieldsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + grantSystemAdminForListTypesTest(t, db, seed.UserID) + + setID := testutils.InsertID(t, db, + `INSERT INTO asset_management_sets (name) VALUES (?)`, "n+1 asset set") + + const typeCount = 4 + for i := 0; i < typeCount; i++ { + typeID := testutils.InsertID(t, db, + `INSERT INTO asset_types (set_id, name) VALUES (?, ?)`, setID, "Type "+strconv.Itoa(i)) + fieldID := testutils.InsertID(t, db, + `INSERT INTO custom_field_definitions (name, field_type) VALUES (?, 'text')`, "Field "+strconv.Itoa(i)) + testutils.InsertID(t, db, ` + INSERT INTO asset_type_fields (asset_type_id, custom_field_id, is_required, display_order) + VALUES (?, ?, false, 0) + `, typeID, fieldID) + } + + counting := testutils.CountQueries(db) + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permService, err := services.NewPermissionService(counting, config) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + t.Cleanup(func() { _ = permService.Close() }) + + assetRepo := repository.NewAssetRepository(counting) + handler := &AssetHandler{ + BaseHandler: NewBaseHandler(counting, permService), + repo: assetRepo, + assetPerm: services.NewAssetPermissionService(assetRepo, permService), + } + + req := httptest.NewRequest(http.MethodGet, "/rest/api/v1/asset-sets/"+strconv.Itoa(setID)+"/types", nil) + req.SetPathValue("setId", strconv.Itoa(setID)) + req = req.WithContext(context.WithValue(req.Context(), restapi.ContextKeyUser, &models.User{ID: seed.UserID})) + recorder := httptest.NewRecorder() + + counting.ResetQueries() + handler.ListTypes(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var types []dto.AssetTypeResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &types); err != nil { + t.Fatalf("decode response: %v (body %s)", err, recorder.Body.String()) + } + if len(types) != typeCount { + t.Fatalf("returned %d types, want %d", len(types), typeCount) + } + for _, assetType := range types { + if len(assetType.Fields) != 1 { + t.Fatalf("type %d has %d fields, want 1", assetType.ID, len(assetType.Fields)) + } + } + + if got := counting.CountQueriesContaining("WHERE atf.asset_type_id = ?"); got != 0 { + t.Fatalf("handler still issues %d per-type field lookups", got) + } + if got := counting.CountQueriesContaining("WHERE atf.asset_type_id IN ("); got != 1 { + t.Fatalf("batched field lookup ran %d times, want 1", got) + } +} diff --git a/internal/restapi/v1/middleware/ratelimit_test.go b/internal/restapi/v1/middleware/ratelimit_test.go deleted file mode 100644 index 5323db7..0000000 --- a/internal/restapi/v1/middleware/ratelimit_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package middleware - -import ( - "strconv" - "sync" - "sync/atomic" - "testing" - "time" -) - -func mapLen(rl *RateLimiter) int { - n := 0 - rl.limiters.Range(func(_, _ any) bool { - n++ - return true - }) - return n -} - -func TestRateLimiterEvictIdleDropsStaleBuckets(t *testing.T) { - rl := NewRateLimiter(100, nil) - defer rl.Stop() - - for i := 0; i < 200; i++ { - bucket := rl.getBucket("tok-" + strconv.Itoa(i)) - bucket.allow(rl.rate, rl.window) - } - if got := mapLen(rl); got != 200 { - t.Fatalf("after seeding, want 200 entries, got %d", got) - } - - rl.evictIdle(time.Now().Add(idleEvictAfter + time.Second)) - - if got := mapLen(rl); got != 0 { - t.Fatalf("after evictIdle with all buckets idle, want 0, got %d", got) - } -} - -func TestRateLimiterEvictIdleKeepsActiveBuckets(t *testing.T) { - rl := NewRateLimiter(100, nil) - defer rl.Stop() - - for i := 0; i < 50; i++ { - bucket := rl.getBucket("active-" + strconv.Itoa(i)) - bucket.allow(rl.rate, rl.window) - } - rl.evictIdle(time.Now()) - - if got := mapLen(rl); got != 50 { - t.Fatalf("active buckets should not be evicted, want 50, got %d", got) - } -} - -func TestRateLimiterStopIsIdempotent(t *testing.T) { - rl := NewRateLimiter(10, nil) - rl.Stop() - rl.Stop() -} - -// Ensure the sweep goroutine actually wakes up and prunes — relies on a -// shortened ticker via a private hook test (so we don't sit on real sweep -// intervals). We adjust the bucket's lastSeen and call evictIdle directly, -// then verify a subsequent getBucket sees a fresh entry. -func TestRateLimiterSweepRecreatesAfterEviction(t *testing.T) { - rl := NewRateLimiter(10, nil) - defer rl.Stop() - - bucket := rl.getBucket("tok-A") - bucket.allow(rl.rate, rl.window) - - bucket.mu.Lock() - bucket.lastSeen = time.Now().Add(-2 * idleEvictAfter) - bucket.mu.Unlock() - - rl.evictIdle(time.Now()) - if got := mapLen(rl); got != 0 { - t.Fatalf("want bucket evicted, got %d entries", got) - } - - freshBucket := rl.getBucket("tok-A") - if freshBucket == bucket { - t.Fatal("getBucket returned the old (evicted) bucket pointer") - } -} - -// Sanity: getBucket under concurrent load with lots of distinct tokens does -// not race after eviction. Just checks the eviction path is safe to run while -// new buckets are being created. -func TestRateLimiterEvictIdleConcurrentSafe(t *testing.T) { - rl := NewRateLimiter(100, nil) - defer rl.Stop() - - var stop atomic.Bool - done := make(chan struct{}) - go func() { - i := 0 - for !stop.Load() { - b := rl.getBucket("tok-" + strconv.Itoa(i)) - b.allow(rl.rate, rl.window) - i++ - } - close(done) - }() - - for i := 0; i < 20; i++ { - rl.evictIdle(time.Now().Add(idleEvictAfter + time.Second)) - } - stop.Store(true) - <-done -} - -// stopLocks makes Stop idempotent per limiter. Core dropped its stopOnce -// field in the dead-code sweep, so the test surface owns the guard. -var stopLocks sync.Map // *RateLimiter -> *sync.Once - -// Stop halts the sweep goroutine. Safe to call multiple times; not currently -// wired into server shutdown since the limiter lives for the process lifetime, -// but exposed so future shutdown paths and tests can release it cleanly. -// -// deadcode-keep: called by core-tests/internal/restapi/v1/middleware/ratelimit_test.go -func (rl *RateLimiter) Stop() { - once, _ := stopLocks.LoadOrStore(rl, &sync.Once{}) - once.(*sync.Once).Do(func() { - if rl.sweepTicker != nil { - rl.sweepTicker.Stop() - } - if rl.stopChan != nil { - close(rl.stopChan) - } - }) -} diff --git a/internal/restapi/v2/audit_coverage_test.go b/internal/restapi/v2/audit_coverage_test.go new file mode 100644 index 0000000..44bb642 --- /dev/null +++ b/internal/restapi/v2/audit_coverage_test.go @@ -0,0 +1,81 @@ +//go:build test + +package v2 + +import ( + "net/http" + "testing" +) + +// TestMutatingRoutesHaveAuditSpec is the WI-1898 coverage guardrail. Every +// mutating route must be classified as AuditRecorder (the handler records via +// logger.Record), AuditService (the application service emits the event), or +// AuditExempt (a reviewed exemption). A new mutating route without a registry +// entry keeps AuditUnspecified and fails here, so it cannot ship unaudited by +// accident the way the v2 admin and attachment surfaces once did. +func TestMutatingRoutesHaveAuditSpec(t *testing.T) { + for _, route := range Inventory() { + if isSafeMethod(route.Method) { + continue + } + switch route.Audit { + case AuditRecorder, AuditService, AuditExempt: + default: + t.Errorf("%s %s (%s) has no audit spec; classify it in auditSpecRegistry", + route.Method, route.Path, route.OperationID) + } + } +} + +// TestAuditRegistryHasNoStaleEntries keeps the registry honest: an entry for a +// route that no longer exists hides a real missing classification behind a +// rename. +func TestAuditRegistryHasNoStaleEntries(t *testing.T) { + live := make(map[string]struct{}) + for _, route := range Inventory() { + if isSafeMethod(route.Method) { + continue + } + live[route.OperationID] = struct{}{} + } + for operationID := range auditSpecRegistry { + if _, ok := live[operationID]; !ok { + t.Errorf("stale audit registry entry %q does not match a mutating route", operationID) + } + } +} + +// TestAuditRegistryClassifiesRecorderSurfaces pins the surfaces that must use +// the request recorder rather than an ad-hoc handler write. +func TestAuditRegistryClassifiesRecorderSurfaces(t *testing.T) { + want := map[string]AuditKind{ + "postAdminGroups": AuditRecorder, + "patchAdminGroupsGroupId": AuditRecorder, + "deleteAdminGroupsGroupId": AuditRecorder, + "patchAdminUsersUserId": AuditRecorder, + "deleteAttachmentsAttachmentId": AuditRecorder, + "postItemsItemIdParticipants": AuditRecorder, + "deleteItemsItemIdParticipantsCustomerId": AuditRecorder, + "deleteAdminApiTokensTokenId": AuditRecorder, + } + for operationID, kind := range want { + if auditSpecRegistry[operationID] != kind { + t.Errorf("%s audit kind = %q, want %q", operationID, auditSpecRegistry[operationID], kind) + } + } +} + +// TestInventoryIncludesAuditKind ensures the public inventory exposes the +// classification used by the guardrails. +func TestInventoryIncludesAuditKind(t *testing.T) { + found := false + for _, route := range Inventory() { + if route.Method == http.MethodDelete && route.Audit == AuditRecorder { + found = true + break + } + } + if !found { + t.Error("expected at least one DELETE route classified as AuditRecorder") + } +} diff --git a/internal/restapi/v2/item_openapi_contract_test.go b/internal/restapi/v2/item_openapi_contract_test.go index e61703f..da82b3a 100644 --- a/internal/restapi/v2/item_openapi_contract_test.go +++ b/internal/restapi/v2/item_openapi_contract_test.go @@ -57,7 +57,7 @@ func TestItemOpenAPIQueryParametersMatchHandlerContract(t *testing.T) { path string want []string }{ - {method: "get", path: "/items", want: []string{"page", "page_size", "workspace_id", "collection_id", "ql", "sub_ql", "search", "status_id", "status_id_not", "priority_id", "assignee_id", "item_type_id", "iteration_id", "milestone_id", "id", "parent_id", "level", "max_level", "created_since", "completed_since", "sort", "cursor", "fields", "include_watermark", "completed_activity_days", "exclude_personal"}}, + {method: "get", path: "/items", want: []string{"page", "page_size", "workspace_id", "collection_id", "ql", "sub_ql", "search", "status_id", "status_id_not", "priority_id", "assignee_id", "item_type_id", "iteration_id", "milestone_id", "id", "parent_id", "level", "max_level", "created_since", "completed_since", "sort", "cursor", "fields", "include_watermark", "completed_activity_days", "exclude_personal", "include_has_children"}}, {method: "post", path: "/items/batch", want: nil}, {path: "/items/{item_id}/detail-summary", want: []string{"surface", "exclude_personal"}}, {path: "/workspaces/{workspace_key}/items/{item_number}/detail-summary", want: []string{"surface", "exclude_personal"}}, @@ -114,6 +114,7 @@ func TestItemOpenAPIRequestBodiesAreStrictAndTyped(t *testing.T) { {method: "put", path: "/items/{item_id}/watch", media: "application/json"}, {method: "post", path: "/items/bulk-update", media: "application/json"}, {method: "post", path: "/items/bulk-patch", media: "application/json"}, + {method: "post", path: "/items/bulk-labels", media: "application/json"}, {method: "post", path: "/items/bulk-transition", media: "application/json"}, {method: "post", path: "/items/bulk-delete", media: "application/json"}, {method: "post", path: "/items/roadmap-hierarchy-dates", media: "application/json"}, @@ -174,6 +175,7 @@ func TestItemOpenAPISuccessResponsesAreTyped(t *testing.T) { {method: "get", path: "/items/{item_id}/status-durations", status: "200"}, {method: "post", path: "/items/bulk-update", status: "200"}, {method: "post", path: "/items/bulk-patch", status: "200"}, + {method: "post", path: "/items/bulk-labels", status: "200"}, {method: "post", path: "/items/bulk-transition", status: "200"}, {method: "post", path: "/items/bulk-delete", status: "200"}, {method: "post", path: "/items/{item_id}/move-workspace/preview", status: "200"}, diff --git a/internal/restapi/v2/item_search_test.go b/internal/restapi/v2/item_search_test.go index 9ed6ae7..06f3519 100644 --- a/internal/restapi/v2/item_search_test.go +++ b/internal/restapi/v2/item_search_test.go @@ -8,13 +8,13 @@ import ( "net/http/httptest" "testing" - "windshift/internal/middleware" "windshift/internal/models" + "windshift/internal/ratelimit" ) func TestItemSearchRateLimitUsesV2Error(t *testing.T) { t.Setenv("WINDSHIFT_E2E_DISABLE_RATE_LIMITS", "0") - limiter := middleware.NewRateLimiter(0, 1, false, nil) + limiter := ratelimit.New(0, 1, ratelimit.ByIP(nil)) t.Cleanup(limiter.Stop) calls := 0 routes := []route{{Route: Route{Method: http.MethodGet, Path: "/items/search", Auth: AuthAuthenticated, Scopes: []string{"items:read"}}, handler: func(w http.ResponseWriter, r *http.Request) error { diff --git a/internal/restapi/v2/labels_test.go b/internal/restapi/v2/labels_test.go index 2a7fafd..c456704 100644 --- a/internal/restapi/v2/labels_test.go +++ b/internal/restapi/v2/labels_test.go @@ -36,6 +36,9 @@ func (*labelFixture) AddToItem(int, int) ([]models.Label, error) { return []models.Label{}, nil } func (*labelFixture) RemoveFromItem(int, int) error { return nil } +func (*labelFixture) BulkApply(services.AuditActor, services.BulkLabelOperation, func(int) (bool, error)) (services.BulkLabelResult, error) { + return services.BulkLabelResult{}, nil +} func (*labelFixture) FindByID(id int) (*models.Item, error) { return &models.Item{ID: id, WorkspaceID: 7}, nil } diff --git a/internal/restapi/v2/milestone_audit_test.go b/internal/restapi/v2/milestone_audit_test.go new file mode 100644 index 0000000..eace64f --- /dev/null +++ b/internal/restapi/v2/milestone_audit_test.go @@ -0,0 +1,88 @@ +//go:build test + +package v2 + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "windshift/internal/contextkeys" + "windshift/internal/logger" + "windshift/internal/models" + "windshift/internal/testutils" +) + +// TestMilestoneMutationsRecordAudit covers the WI-1898 recorder contract for the +// milestone comment and page-link routes: each mutation must queue one audit +// event with the request actor before the handler returns. +func TestMilestoneMutationsRecordAudit(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + auditor := logger.NewAuditor(tdb.GetDatabase()) + + run := func(t *testing.T, method, milestoneID string, pathValues map[string]string, operation commandOperation) { + t.Helper() + recorder := logger.NewAuditRecorder() + request := httptest.NewRequest(method, "/api/v2/milestones/"+milestoneID, nil) + request.SetPathValue("milestone_id", milestoneID) + for name, value := range pathValues { + request.SetPathValue(name, value) + } + request = request.WithContext(context.WithValue(request.Context(), contextkeys.User, &models.User{ID: seed.UserID, Username: "ada"})) + request = request.WithContext(logger.WithAuditRecorder(request.Context(), recorder)) + if err := operation(request); err != nil { + t.Fatalf("%s milestone %s: %v", method, milestoneID, err) + } + auditor.Flush(recorder, true) + } + + run(t, http.MethodPost, "5", nil, func(r *http.Request) error { + _, err := createMilestoneComment(fakeMilestoneComments{})(r, milestoneCommentCreateRequest{Content: "hello"}) + return err + }) + run(t, http.MethodPatch, "5", map[string]string{"comment_id": "9"}, func(r *http.Request) error { + _, err := updateMilestoneComment(fakeMilestoneComments{})(r, milestoneCommentPatchRequest{Content: Optional[string]{Set: true, Value: "edited"}}) + return err + }) + run(t, http.MethodDelete, "5", map[string]string{"comment_id": "9"}, deleteMilestoneComment(fakeMilestoneComments{})) + run(t, http.MethodPost, "5", nil, func(r *http.Request) error { + _, err := createMilestonePageLink(fakeMilestonePageLinks{})(r, milestonePageLinkCreateRequest{PageID: 7}) + return err + }) + run(t, http.MethodDelete, "5", map[string]string{"link_id": "3"}, deleteMilestonePageLink(fakeMilestonePageLinks{})) + + rows, err := tdb.Query(`SELECT action_type, resource_type FROM audit_logs WHERE resource_type IN (?, ?) ORDER BY id`, + logger.ResourceMilestoneComment, logger.ResourceMilestonePageLink) + if err != nil { + t.Fatalf("query audit rows: %v", err) + } + defer func() { _ = rows.Close() }() + + type auditRow struct{ action, resource string } + var got []auditRow + for rows.Next() { + var row auditRow + if err := rows.Scan(&row.action, &row.resource); err != nil { + t.Fatalf("scan audit row: %v", err) + } + got = append(got, row) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate audit rows: %v", err) + } + + want := []auditRow{ + {logger.ActionMilestoneCommentCreate, logger.ResourceMilestoneComment}, + {logger.ActionMilestoneCommentUpdate, logger.ResourceMilestoneComment}, + {logger.ActionMilestoneCommentDelete, logger.ResourceMilestoneComment}, + {logger.ActionMilestonePageLinkCreate, logger.ResourceMilestonePageLink}, + {logger.ActionMilestonePageLinkDelete, logger.ResourceMilestonePageLink}, + } + if !slices.Equal(got, want) { + t.Fatalf("audit rows = %v, want %v", got, want) + } +} diff --git a/internal/restapi/v2/preferences_test.go b/internal/restapi/v2/preferences_test.go index 540ffbb..60851fa 100644 --- a/internal/restapi/v2/preferences_test.go +++ b/internal/restapi/v2/preferences_test.go @@ -25,6 +25,7 @@ func (f *preferencesFixture) GetSnapshot(int) (services.UserPreferencesSnapshot, SavedFilters: map[string]any{"collection-5": []any{map[string]any{"id": "a"}}}, DueDateRemindersEnabled: true, DueDateReminderDays: 7, + CommentSortOrder: services.CommentSortNewest, }, nil } func (f *preferencesFixture) UpdateSnapshot(_ int, patch services.UserPreferencesPatch) (services.UserPreferencesSnapshot, error) { @@ -96,6 +97,43 @@ func TestPreferencePatchCarriesViewStateAndSavedFilters(t *testing.T) { } } +func TestPreferencesCarryCommentSortOrder(t *testing.T) { + fixture := &preferencesFixture{} + mux, _ := newTestMuxWithCatalog(t, true, fixture) + + getRequest := httptest.NewRequest(http.MethodGet, "/api/v2/users/me/preferences", nil) + getRequest.Header.Set("X-Test-Session", "valid") + getResponse := httptest.NewRecorder() + mux.ServeHTTP(getResponse, getRequest) + if getResponse.Code != http.StatusOK { + t.Fatalf("get status = %d; body=%s", getResponse.Code, getResponse.Body) + } + var body struct { + Data struct { + CommentSortOrder string `json:"comment_sort_order"` + } `json:"data"` + } + if err := json.Unmarshal(getResponse.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Data.CommentSortOrder != "newest" { + t.Fatalf("comment sort order = %q, want newest", body.Data.CommentSortOrder) + } + + patchRequest := httptest.NewRequest(http.MethodPatch, "/api/v2/users/me/preferences", + bytes.NewBufferString(`{"comment_sort_order":"newest"}`)) + patchRequest.Header.Set("Content-Type", "application/merge-patch+json") + patchRequest.Header.Set("X-Test-Session", "valid") + patchResponse := httptest.NewRecorder() + mux.ServeHTTP(patchResponse, patchRequest) + if patchResponse.Code != http.StatusOK { + t.Fatalf("patch status = %d; body=%s", patchResponse.Code, patchResponse.Body) + } + if !fixture.last.CommentSortOrderSet || fixture.last.CommentSortOrder != "newest" { + t.Fatalf("comment sort order patch = %#v", fixture.last) + } +} + func TestPreferencesCarryDueDateReminders(t *testing.T) { fixture := &preferencesFixture{} mux, _ := newTestMuxWithCatalog(t, true, fixture) diff --git a/internal/restapi/v2/rate_limit_test.go b/internal/restapi/v2/rate_limit_test.go new file mode 100644 index 0000000..53a529c --- /dev/null +++ b/internal/restapi/v2/rate_limit_test.go @@ -0,0 +1,67 @@ +//go:build test + +package v2 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "windshift/internal/models" + "windshift/internal/ratelimit" +) + +func TestAuthenticatedRoutesUseSharedRateLimiter(t *testing.T) { + t.Setenv("WINDSHIFT_E2E_DISABLE_RATE_LIMITS", "0") + limiter := ratelimit.New(0, 1, ratelimit.ByUser()) + t.Cleanup(limiter.Stop) + + calls := 0 + routes := []route{{Route: Route{Method: http.MethodGet, Path: "/items", Auth: AuthAuthenticated, Scopes: []string{"items:read"}}, handler: func(w http.ResponseWriter, _ *http.Request) error { + calls++ + w.WriteHeader(http.StatusNoContent) + return nil + }}} + + mux := http.NewServeMux() + registerMount(mux, restPrefix, routes, Deps{ + Tokens: fakeTokens{user: &models.User{ID: 1}, token: &models.APIToken{ID: 1}, allow: true, wantScopes: []string{"items:read"}}, + Concurrency: fakeConcurrency{allow: true}, + CORS: NewCORS(nil, false, true), + RateLimiter: limiter, + }, false) + + request := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/rest/api/v2/items", nil) + req.Header.Set("Authorization", "Bearer crw_valid") + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, req) + return recorder + } + + if got := request(); got.Code != http.StatusNoContent { + t.Fatalf("first request status = %d, want 204: %s", got.Code, got.Body) + } + + denied := request() + if denied.Code != http.StatusTooManyRequests { + t.Fatalf("second request status = %d, want 429: %s", denied.Code, denied.Body) + } + if denied.Header().Get("Retry-After") == "" { + t.Fatal("429 missing Retry-After") + } + if got := denied.Header().Get("X-RateLimit-Limit"); got != "1" { + t.Fatalf("X-RateLimit-Limit = %q, want 1", got) + } + var body errorBody + if err := json.Unmarshal(denied.Body.Bytes(), &body); err != nil { + t.Fatalf("decode 429 body: %v (%s)", err, denied.Body) + } + if body.Error.Code != "rate_limited" || body.RequestID == "" { + t.Fatalf("429 body = %+v, want a v2 rate_limited error with a request id", body) + } + if calls != 1 { + t.Fatalf("handler ran %d times, want once", calls) + } +} diff --git a/internal/restapi/v2/router_test.go b/internal/restapi/v2/router_test.go index edd33fe..c6a267f 100644 --- a/internal/restapi/v2/router_test.go +++ b/internal/restapi/v2/router_test.go @@ -183,6 +183,9 @@ func (fakeLabels) AddToItem(services.AuditActor, int, int) ([]models.Label, erro return []models.Label{}, nil } func (fakeLabels) RemoveFromItem(services.AuditActor, int, int) error { return nil } +func (fakeLabels) BulkApply(services.AuditActor, services.BulkLabelOperation, func(int) (bool, error)) (services.BulkLabelResult, error) { + return services.BulkLabelResult{}, nil +} type fakeParticipants struct{} @@ -280,6 +283,9 @@ func (fakePageApplication) List(int, int) ([]models.Page, error) { func (fakePageApplication) ListTitlesAcrossWorkspaces(int, []int) ([]services.PageTitleRow, error) { return []services.PageTitleRow{}, nil } +func (fakePageApplication) ListSummariesAcrossWorkspaces(int, []int, string, int) ([]services.PageSummaryRow, error) { + return []services.PageSummaryRow{}, nil +} func (fakePageApplication) EffectiveLevels(int, int) (map[int]string, error) { return map[int]string{}, nil } @@ -613,6 +619,36 @@ func (fakeComments) AddReaction(int, int, string) ([]models.CommentReaction, err } func (fakeComments) RemoveReaction(int, int, string) error { return nil } +type fakeMilestoneComments struct{} + +func (fakeMilestoneComments) GetFeed(int, int, services.CommentFeedOptions) (*services.MilestoneCommentFeedPage, error) { + return &services.MilestoneCommentFeedPage{Comments: []models.MilestoneComment{}}, nil +} +func (fakeMilestoneComments) CountFeed(int) (int, error) { return 0, nil } +func (fakeMilestoneComments) Create(int, int, string) (*models.MilestoneComment, error) { + return &models.MilestoneComment{ID: 1}, nil +} +func (fakeMilestoneComments) Update(int, int, int, string) (*models.MilestoneComment, error) { + return &models.MilestoneComment{ID: 1}, nil +} +func (fakeMilestoneComments) Delete(int, int, int) error { return nil } + +type fakeMilestonePageLinks struct{} + +func (fakeMilestonePageLinks) List(int, int) ([]models.MilestonePageLink, error) { + return []models.MilestonePageLink{}, nil +} +func (fakeMilestonePageLinks) Create(int, int, int) (*models.MilestonePageLink, error) { + return &models.MilestonePageLink{ID: 1}, nil +} +func (fakeMilestonePageLinks) Delete(int, int, int) error { return nil } + +type fakeMilestoneActivity struct{} + +func (fakeMilestoneActivity) List(int, int, services.MilestoneActivityListParams) ([]models.MilestoneActivity, bool, error) { + return []models.MilestoneActivity{}, false, nil +} + type fakeAttachments struct{} func (fakeAttachments) UploadPolicy() (services.ItemAttachmentUploadPolicy, error) { @@ -630,6 +666,9 @@ func (fakeAttachments) OpenItemAttachment(context.Context, int, int, bool) (*ser func (fakeAttachments) UploadItemAttachment(context.Context, services.ItemAttachmentUploadInput) (models.AttachmentUploadResponse, error) { return models.AttachmentUploadResponse{Success: true, Attachment: models.Attachment{ID: 1}}, nil } +func (fakeAttachments) SetItemAttachmentInternal(int, int, bool) (*models.Attachment, error) { + return &models.Attachment{ID: 1}, nil +} func (fakeAttachments) DeleteItemAttachment(context.Context, int, int) error { return nil } type fakePageAttachments struct{} @@ -684,6 +723,9 @@ func (fakeCollections) GetBoardConfigurationBootstrap(context.Context, int, serv func (fakeCollections) PutBoardConfiguration(services.AuditActor, services.BoardConfigurationScope, models.BoardConfigurationRequest) (*models.BoardConfiguration, error) { return &models.BoardConfiguration{ID: 1}, nil } +func (fakeCollections) PatchBoardConfiguration(services.AuditActor, services.BoardConfigurationScope, services.BoardConfigurationPatch) (*models.BoardConfiguration, error) { + return &models.BoardConfiguration{ID: 1}, nil +} func (fakeCollections) DeleteBoardConfiguration(services.AuditActor, services.BoardConfigurationScope) error { return nil } @@ -865,6 +907,9 @@ func (fakeGovernance) EscalateApproval(context.Context, services.AuditActor, int func (fakeGovernance) GetTransitionGovernance(context.Context, int) (*services.TransitionGovernance, error) { return &services.TransitionGovernance{TransitionID: 1}, nil } +func (fakeGovernance) SetTransitionScreen(context.Context, services.AuditActor, int, *int) error { + return nil +} type fakeActions struct{} @@ -1333,60 +1378,63 @@ func newTestMuxWithCatalog(t *testing.T, allowScope bool, catalog catalogReader, tokenAuth = tokens[0] } err := RegisterRoutes(Deps{ - Mux: mux, - Tokens: tokenAuth, - Users: fakeUsers{user: user}, - Statuses: statuses, - Workflows: fakeCatalogs{}, - Configuration: configuration, - Catalog: catalog, - CatalogMutations: fakeCatalogMutations{}, - Workspaces: fakeWorkspaces{}, - ItemTemplates: fakeItemTemplates{}, - Labels: labels, - Participants: fakeParticipants{}, - Items: items, - Access: access, - Preferences: preferences, - Recurrence: recurrence, - ItemDiagrams: itemDiagrams, - Pages: pages, - PageApplication: pageApplicationDependency, - PageDiagrams: pageDiagrams, - PageAccess: pageAccessDependency, - PageLabels: pageLabels, - CannedResponses: fakeCannedResponses{}, - PagePublication: services.NewKnowledgePublicationService(nil), - Worklogs: worklogs, - TimeAccess: timeAccessDependency, - TimeProjects: fakeTimeProjects{}, - Timers: fakeTimers{}, - SystemAdmins: fakeSystemAdmins{}, - Groups: fakeGroups{}, - AdminUsers: fakeAdminUsers{user: user}, - AuditLogs: repository.NewAuditLogRepository(nil), - AdminTokens: &auth.TokenManager{}, - AdminAuditor: logger.NewAuditor(nil), - AdminTranslations: objecttranslation.NewService(nil), - Comments: fakeComments{}, - CommentAccess: fakeAccess{}, - Attachments: fakeAttachments{}, - PageAttachments: fakePageAttachments{}, - Collections: fakeCollections{}, - Planning: fakePlanning{}, - Links: fakeLinks{}, - AgentRuns: fakeAgentRuns{}, - AgentSkills: fakeAgentSkills{}, - ConditionSets: fakeConditionSets{}, - Governance: fakeGovernance{}, - Actions: fakeActions{}, - TestManagement: services.NewTestManagementApplicationService(nil, nil), - Assets: services.NewAssetApplicationService(nil, nil, nil, nil), - ItemApplication: services.NewItemApplicationService(nil, nil, nil, nil, nil), - ItemDetail: services.NewItemDetailApplicationService(nil, nil, nil, nil), - SessionMiddleware: sessionMiddleware, - CORS: NewCORS([]string{"https://app.example.test"}, false, true), - Concurrency: fakeConcurrency{allow: true}, + Mux: mux, + Tokens: tokenAuth, + Users: fakeUsers{user: user}, + Statuses: statuses, + Workflows: fakeCatalogs{}, + Configuration: configuration, + Catalog: catalog, + CatalogMutations: fakeCatalogMutations{}, + Workspaces: fakeWorkspaces{}, + ItemTemplates: fakeItemTemplates{}, + Labels: labels, + Participants: fakeParticipants{}, + Items: items, + Access: access, + Preferences: preferences, + Recurrence: recurrence, + ItemDiagrams: itemDiagrams, + Pages: pages, + PageApplication: pageApplicationDependency, + PageDiagrams: pageDiagrams, + PageAccess: pageAccessDependency, + PageLabels: pageLabels, + CannedResponses: fakeCannedResponses{}, + PagePublication: services.NewKnowledgePublicationService(nil), + Worklogs: worklogs, + TimeAccess: timeAccessDependency, + TimeProjects: fakeTimeProjects{}, + Timers: fakeTimers{}, + SystemAdmins: fakeSystemAdmins{}, + Groups: fakeGroups{}, + AdminUsers: fakeAdminUsers{user: user}, + AuditLogs: repository.NewAuditLogRepository(nil), + AdminTokens: &auth.TokenManager{}, + AdminAuditor: logger.NewAuditor(nil), + AdminTranslations: objecttranslation.NewService(nil), + Comments: fakeComments{}, + CommentAccess: fakeAccess{}, + Attachments: fakeAttachments{}, + PageAttachments: fakePageAttachments{}, + Collections: fakeCollections{}, + Planning: fakePlanning{}, + MilestoneComments: fakeMilestoneComments{}, + MilestonePageLinks: fakeMilestonePageLinks{}, + MilestoneActivity: fakeMilestoneActivity{}, + Links: fakeLinks{}, + AgentRuns: fakeAgentRuns{}, + AgentSkills: fakeAgentSkills{}, + ConditionSets: fakeConditionSets{}, + Governance: fakeGovernance{}, + Actions: fakeActions{}, + TestManagement: services.NewTestManagementApplicationService(nil, nil), + Assets: services.NewAssetApplicationService(nil, nil, nil, nil), + ItemApplication: services.NewItemApplicationService(nil, nil, nil, nil, nil), + ItemDetail: services.NewItemDetailApplicationService(nil, nil, nil, nil), + SessionMiddleware: sessionMiddleware, + CORS: NewCORS([]string{"https://app.example.test"}, false, true), + Concurrency: fakeConcurrency{allow: true}, }) if err != nil { t.Fatalf("register routes: %v", err) diff --git a/internal/scheduler/cfv_cleanup_scheduler_test.go b/internal/scheduler/cfv_cleanup_scheduler_test.go index e5bc0e8..63f69d1 100644 --- a/internal/scheduler/cfv_cleanup_scheduler_test.go +++ b/internal/scheduler/cfv_cleanup_scheduler_test.go @@ -117,7 +117,7 @@ func TestStripCFVOptionIDsRemovesNumericStrings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cleaned, changed, err := stripCFVOptionIDs(tt.input, "91", tt.fieldType, map[int]bool{5: true}) + cleaned, changed, err := stripCFVOptionIDs(tt.input, []string{"91"}, tt.fieldType, map[int]bool{5: true}) if err != nil { t.Fatalf("stripCFVOptionIDs: %v", err) } diff --git a/internal/scheduler/due_date_reminder_scheduler_test.go b/internal/scheduler/due_date_reminder_scheduler_test.go index e29f1bf..03ca15f 100644 --- a/internal/scheduler/due_date_reminder_scheduler_test.go +++ b/internal/scheduler/due_date_reminder_scheduler_test.go @@ -475,3 +475,98 @@ func TestDueDateReminderSchedulerStartStopAreIdempotent(t *testing.T) { s.Stop() s.Stop() } + +// TestDueDateReminderSchedulerSkipsMergedDuplicate proves a duplicate folded +// into a canonical item does not generate a reminder: the work now lives on +// the canonical item, and every other item surface hides merged rows. +func TestDueDateReminderSchedulerSkipsMergedDuplicate(t *testing.T) { + f := newReminderFixture(t) + f.enableReminders(t, f.assigneeID, 7) + now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC) + due := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) + + canonical := f.createItem(t, f.workspaceID, "Canonical", &due, &f.assigneeID, &f.statusID) + duplicate := f.createItem(t, f.workspaceID, "Duplicate", &due, &f.assigneeID, &f.statusID) + if _, err := f.db.ExecWrite(`UPDATE items SET merged_into_item_id = ? WHERE id = ?`, canonical, duplicate); err != nil { + t.Fatalf("mark duplicate merged: %v", err) + } + + f.scheduler.RunOnce(context.Background(), now) + if got := f.reminderCount(t, duplicate); got != 0 { + t.Fatalf("merged duplicate reminder count = %d, want 0", got) + } + if got := f.reminderCount(t, canonical); got != 1 { + t.Fatalf("canonical reminder count = %d, want 1", got) + } +} + +// TestDueDateReminderSchedulerIncludesFinalLeadDayWithTimeComponent is the +// regression test for the horizon truncating the last day: a due date on the +// max-lead day carrying a time component must still be inside the window. +func TestDueDateReminderSchedulerIncludesFinalLeadDayWithTimeComponent(t *testing.T) { + f := newReminderFixture(t) + f.enableReminders(t, f.assigneeID, 30) + now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC) + due := time.Date(2026, 10, 31, 15, 0, 0, 0, time.UTC) // 30 days out, mid-afternoon + itemID := f.createItem(t, f.workspaceID, "Final lead day", &due, &f.assigneeID, &f.statusID) + + f.scheduler.RunOnce(context.Background(), now) + if got := f.reminderCount(t, itemID); got != 1 { + t.Fatalf("final-lead-day reminder count = %d, want 1", got) + } +} + +// TestDueDateReminderSchedulerRetriesSkippedRecipientAfterBackoff proves a +// recipient filtered out (here: inactive) is not lost: the skipped candidate +// is held back for the retry interval and reminded once the recipient becomes +// eligible again. +func TestDueDateReminderSchedulerRetriesSkippedRecipientAfterBackoff(t *testing.T) { + f := newReminderFixture(t) + userID, err := factory.NewTestFactory(f.db).CreateUser(&factory.CreateUserOpts{Email: "reminder-retry@example.com", Username: "reminder-retry", IsActive: false}) + if err != nil { + t.Fatalf("create inactive user: %v", err) + } + f.enableReminders(t, userID, 7) + now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC) + due := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) + itemID := f.createItem(t, f.workspaceID, "Retry me", &due, &userID, &f.statusID) + + f.scheduler.RunOnce(context.Background(), now) + if got := f.reminderCount(t, itemID); got != 0 { + t.Fatalf("reminder count while inactive = %d, want 0", got) + } + + if _, err := f.db.ExecWrite(`UPDATE users SET is_active = true WHERE id = ?`, userID); err != nil { + t.Fatalf("reactivate user: %v", err) + } + // Within the retry backoff the skipped candidate must not be re-selected. + f.scheduler.RunOnce(context.Background(), now.Add(time.Hour)) + if got := f.reminderCount(t, itemID); got != 0 { + t.Fatalf("reminder count within backoff = %d, want 0", got) + } + + // After the backoff the now-active recipient gets the reminder. + f.scheduler.RunOnce(context.Background(), now.Add(repository.DueReminderRetryInterval+time.Hour)) + if got := f.reminderCount(t, itemID); got != 1 { + t.Fatalf("reminder count after backoff = %d, want 1", got) + } +} + +// TestDueDateReminderSchedulerSkipsInactiveWorkspace proves a deactivated +// workspace produces no reminder, matching the tray/digest authorization that +// only exposes active workspaces. +func TestDueDateReminderSchedulerSkipsInactiveWorkspace(t *testing.T) { + f := newReminderFixture(t) + f.enableReminders(t, f.assigneeID, 7) + now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC) + due := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) + itemID := f.createItem(t, f.workspaceID, "Inactive workspace", &due, &f.assigneeID, &f.statusID) + if _, err := f.db.ExecWrite(`UPDATE workspaces SET active = false WHERE id = ?`, f.workspaceID); err != nil { + t.Fatalf("deactivate workspace: %v", err) + } + + f.scheduler.RunOnce(context.Background(), now) + if got := f.reminderCount(t, itemID); got != 0 { + t.Fatalf("inactive workspace reminder count = %d, want 0", got) + } +} diff --git a/internal/scheduler/notification_email_claim_scheduler_test.go b/internal/scheduler/notification_email_claim_scheduler_test.go index 4de82f3..9de92fb 100644 --- a/internal/scheduler/notification_email_claim_scheduler_test.go +++ b/internal/scheduler/notification_email_claim_scheduler_test.go @@ -36,7 +36,7 @@ type claimTestSMTPRecord struct { func (s *claimTestSMTPSender) IsSMTPConfigured() bool { return !s.unconfigured } -func (s *claimTestSMTPSender) SendBatchedNotificationsContext(ctx context.Context, email string, _ string, notifications []models.Notification) error { +func (s *claimTestSMTPSender) SendBatchedNotificationsContext(ctx context.Context, email string, _ string, _, _ string, notifications []models.Notification) error { call := int(s.calls.Add(1)) if s.started != nil && call == 1 { s.once.Do(func() { close(s.started) }) diff --git a/internal/scheduler/outbound_email_scheduler_test.go b/internal/scheduler/outbound_email_scheduler_test.go index bf56978..92a1ae9 100644 --- a/internal/scheduler/outbound_email_scheduler_test.go +++ b/internal/scheduler/outbound_email_scheduler_test.go @@ -14,7 +14,7 @@ import ( type configuredSMTPSender struct{} -func (configuredSMTPSender) SendBatchedNotificationsContext(context.Context, string, string, []models.Notification) error { +func (configuredSMTPSender) SendBatchedNotificationsContext(context.Context, string, string, string, string, []models.Notification) error { return nil } diff --git a/internal/scheduler/scheduler_lifecycle_test.go b/internal/scheduler/scheduler_lifecycle_test.go index cc8d87b..41cf043 100644 --- a/internal/scheduler/scheduler_lifecycle_test.go +++ b/internal/scheduler/scheduler_lifecycle_test.go @@ -11,7 +11,7 @@ import ( type noopSMTPSender struct{} -func (noopSMTPSender) SendBatchedNotificationsContext(context.Context, string, string, []models.Notification) error { +func (noopSMTPSender) SendBatchedNotificationsContext(context.Context, string, string, string, string, []models.Notification) error { return nil } diff --git a/internal/search/prefix_test.go b/internal/search/prefix_test.go new file mode 100644 index 0000000..e2eb8cd --- /dev/null +++ b/internal/search/prefix_test.go @@ -0,0 +1,54 @@ +//go:build test + +package search + +import ( + "reflect" + "testing" +) + +func TestTokens(t *testing.T) { + for _, tt := range []struct { + name string + raw string + want []string + }{ + {name: "lowercases and splits on punctuation", raw: "Outlook-Version", want: []string{"outlook", "version"}}, + {name: "keeps digits", raw: "Outlook 2026", want: []string{"outlook", "2026"}}, + {name: "drops single character terms", raw: "a outlook", want: []string{"outlook"}}, + {name: "two character term is kept", raw: "ok", want: []string{"ok"}}, + {name: "underscore separates", raw: "foo_bar", want: []string{"foo", "bar"}}, + {name: "cyrillic is tokenized", raw: "ВĐĩŅ€ŅĐ¸ŅŽ", want: []string{"вĐĩŅ€ŅĐ¸ŅŽ"}}, + {name: "empty query", raw: "", want: nil}, + {name: "only punctuation", raw: "!!!", want: nil}, + {name: "only short terms", raw: "a b c", want: nil}, + {name: "cjk query falls back", raw: "į‰ˆæœŦ", want: nil}, + {name: "mixed cjk and latin falls back", raw: "outlook į‰ˆæœŦ", want: nil}, + {name: "thai query falls back", raw: "āš€ā¸§ā¸­ā¸ŖāšŒā¸Šā¸ąā¸™", want: nil}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := Tokens(tt.raw); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Tokens(%q) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestTokensCapsTerms(t *testing.T) { + got := Tokens("one two three four five six seven eight nine ten") + if len(got) != maxTokens { + t.Fatalf("Tokens returned %d terms, want %d: %v", len(got), maxTokens, got) + } +} + +func TestSQLiteMatch(t *testing.T) { + if got := SQLiteMatch([]string{"outlo", "versio"}); got != "outlo* versio*" { + t.Fatalf("SQLiteMatch = %q, want %q", got, "outlo* versio*") + } +} + +func TestPostgresTSQuery(t *testing.T) { + if got := PostgresTSQuery([]string{"outlo", "versio"}); got != "outlo:* & versio:*" { + t.Fatalf("PostgresTSQuery = %q, want %q", got, "outlo:* & versio:*") + } +} diff --git a/internal/server/context_path_test.go b/internal/server/context_path_test.go index 8237850..18bd6f4 100644 --- a/internal/server/context_path_test.go +++ b/internal/server/context_path_test.go @@ -163,11 +163,17 @@ func TestWithContextPathDisabledLeavesLocationAlone(t *testing.T) { func TestPrepareIndexHTMLPrefixesRootAssetsAndInjectsContextPath(t *testing.T) { input := []byte(``) - got := string(prepareIndexHTML(input, "nonce123", "/windshift")) + got := string(prepareIndexHTML(input, "nonce123", indexBootstrap{ + ContextPath: "/windshift", + BaseURL: "https://windshift.example.test", + BaseURLConfigured: true, + })) for _, want := range []string{ ``, `window.__WINDSHIFT_CONTEXT_PATH__="/windshift"`, + `window.__WINDSHIFT_BASE_URL__="https://windshift.example.test"`, + `window.__WINDSHIFT_BASE_URL_CONFIGURED__=true`, `src="/windshift/_app/app.js"`, `href="/windshift/_app/app.css"`, `href="/windshift/windshift-3.svg"`, @@ -177,3 +183,21 @@ func TestPrepareIndexHTMLPrefixesRootAssetsAndInjectsContextPath(t *testing.T) { } } } + +func TestPrepareIndexHTMLInjectsUnconfiguredBaseURLFallback(t *testing.T) { + input := []byte(``) + got := string(prepareIndexHTML(input, "nonce123", indexBootstrap{ + ContextPath: "", + BaseURL: "http://localhost:8080", + BaseURLConfigured: false, + })) + + for _, want := range []string{ + `window.__WINDSHIFT_BASE_URL__="http://localhost:8080"`, + `window.__WINDSHIFT_BASE_URL_CONFIGURED__=false`, + } { + if !strings.Contains(got, want) { + t.Fatalf("prepared HTML missing %q in %s", want, got) + } + } +} diff --git a/internal/server/h2c_test.go b/internal/server/h2c_test.go new file mode 100644 index 0000000..f3663f7 --- /dev/null +++ b/internal/server/h2c_test.go @@ -0,0 +1,146 @@ +//go:build test + +package server + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + "strings" + "sync" + "testing" +) + +func TestConfigureServerProtocols(t *testing.T) { + tests := []struct { + name string + enableHTTPS bool + h2cEnabled bool + wantH2C bool + }{ + {name: "plain listener without h2c stays HTTP/1", wantH2C: false}, + {name: "plain listener with h2c accepts unencrypted HTTP/2", h2cEnabled: true, wantH2C: true}, + {name: "TLS listener negotiates h2 via ALPN", enableHTTPS: true, h2cEnabled: true, wantH2C: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &http.Server{} + configureServerProtocols(server, tt.enableHTTPS, tt.h2cEnabled) + + if !tt.wantH2C { + if server.Protocols != nil { + t.Fatalf("Protocols = %v, want nil (library defaults)", server.Protocols) + } + return + } + if server.Protocols == nil { + t.Fatal("Protocols = nil, want HTTP/1 + unencrypted HTTP/2") + } + if !server.Protocols.HTTP1() || !server.Protocols.UnencryptedHTTP2() { + t.Fatalf("Protocols = %v, want HTTP1 + UnencryptedHTTP2", server.Protocols) + } + }) + } +} + +// openStream issues one request and reads the first SSE frame, leaving the body +// open so the stream stays live. It reports the negotiated protocol and the +// client-side connection address so the caller can assert multiplexing. +func openStream(t *testing.T, client *http.Client, url string, gotConn func(net.Addr)) (string, string, io.ReadCloser, error) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", "", nil, err + } + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { gotConn(info.Conn.LocalAddr()) }, + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + resp, err := client.Do(req) + if err != nil { + return "", "", nil, err + } + line, err := bufio.NewReader(resp.Body).ReadString('\n') + return resp.Proto, line, resp.Body, err +} + +// TestH2CMultiplexesConcurrentStreams proves the wiring accepts h2c and that two +// concurrent long-lived streams share one connection — the property that keeps +// the proxy -> app socket count at one per tab. +func TestH2CMultiplexesConcurrentStreams(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "event: connected\n\n") + w.(http.Flusher).Flush() + select { + case <-r.Context().Done(): + case <-release: + } + }) + + server := &http.Server{Handler: handler} + configureServerProtocols(server, false, true) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = server.Close() }) + go func() { _ = server.Serve(ln) }() + + // A client with h2c only forces cleartext HTTP/2 (Go uses it for http:// URLs + // only when UnencryptedHTTP2 is set and HTTP/1 is not). + protocols := new(http.Protocols) + protocols.SetUnencryptedHTTP2(true) + client := &http.Client{Transport: &http.Transport{Protocols: protocols}} + t.Cleanup(client.CloseIdleConnections) + + var mu sync.Mutex + var connAddrs []string + record := func(addr net.Addr) { + mu.Lock() + connAddrs = append(connAddrs, addr.String()) + mu.Unlock() + } + url := "http://" + ln.Addr().String() + "/events" + + // The first stream establishes the connection and stays open. + proto, frame, firstBody, err := openStream(t, client, url, record) + if err != nil { + t.Fatalf("first stream: %v", err) + } + defer firstBody.Close() + if proto != "HTTP/2.0" { + t.Fatalf("first stream proto = %s, want HTTP/2.0", proto) + } + if !strings.Contains(frame, "event: connected") { + t.Fatalf("first stream frame = %q", frame) + } + + // The second stream must reuse the same connection. + proto, frame, secondBody, err := openStream(t, client, url, record) + if err != nil { + t.Fatalf("second stream: %v", err) + } + defer secondBody.Close() + if proto != "HTTP/2.0" { + t.Fatalf("second stream proto = %s, want HTTP/2.0", proto) + } + if !strings.Contains(frame, "event: connected") { + t.Fatalf("second stream frame = %q", frame) + } + + mu.Lock() + defer mu.Unlock() + if len(connAddrs) != 2 { + t.Fatalf("GotConn called %d times, want 2", len(connAddrs)) + } + if connAddrs[0] != connAddrs[1] { + t.Fatalf("streams used different connections (%v), want one", connAddrs) + } +} diff --git a/internal/server/llm_proxy_test.go b/internal/server/llm_proxy_test.go deleted file mode 100644 index e31bc8d..0000000 --- a/internal/server/llm_proxy_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestInternalLLMProxyRejectsOversizedBody(t *testing.T) { - const secret = "shared-secret" - handler := NewInternalLLMProxy(nil, secret) - body := `{"content":"` + strings.Repeat("a", internalLLMProxyMaxBody) + `"}` - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/internal/llm/v1/chat/completions", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+secret) - req.ContentLength = -1 - - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status = %d, want 413 (body %q)", rec.Code, rec.Body.String()) - } - var response struct { - Error string `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { - t.Fatalf("decode response: %v", err) - } - if response.Error != "request body too large" { - t.Fatalf("error = %q, want %q", response.Error, "request body too large") - } -} - -func TestValidateInternalTokenRequiresBearerPrefix(t *testing.T) { - const secret = "shared-secret" - - cases := []struct { - name string - header string - want bool - }{ - {name: "valid bearer", header: "Bearer " + secret, want: true}, - {name: "wrong prefix same length", header: "Token: " + secret, want: false}, - {name: "missing space", header: "Bearer" + secret, want: false}, - {name: "wrong secret", header: "Bearer nope", want: false}, - {name: "empty", header: "", want: false}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/internal/llm", nil) - if tc.header != "" { - req.Header.Set("Authorization", tc.header) - } - if got := validateInternalToken(req, secret); got != tc.want { - t.Fatalf("validateInternalToken() = %v, want %v", got, tc.want) - } - }) - } -} diff --git a/internal/services/action_create_milestone_test.go b/internal/services/action_create_milestone_test.go index 226ef13..7eda7cc 100644 --- a/internal/services/action_create_milestone_test.go +++ b/internal/services/action_create_milestone_test.go @@ -76,10 +76,20 @@ func newCreateMilestoneTestDB(t *testing.T) database.Database { external_key TEXT, position INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_activity_at DATETIME )`, `CREATE UNIQUE INDEX uq_milestones_workspace_external_key ON milestones(workspace_id, external_key) WHERE external_key IS NOT NULL`, + `CREATE TABLE milestone_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + milestone_id INTEGER NOT NULL, + user_id INTEGER, + field_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, `CREATE TABLE milestone_releases ( id INTEGER PRIMARY KEY AUTOINCREMENT, milestone_id INTEGER NOT NULL, diff --git a/internal/services/approval_channel_security_test.go b/internal/services/approval_channel_security_test.go index 5c0ba78..a9e37cd 100644 --- a/internal/services/approval_channel_security_test.go +++ b/internal/services/approval_channel_security_test.go @@ -21,6 +21,12 @@ type approvalChannelFixture struct { requestB int userID int customerID int + + // Wiring reused by intake-linked request setup. + workspaceID int + statusID int + approvalSetStatusID int + approvalStepID int } func setupApprovalChannelFixture(t *testing.T) approvalChannelFixture { @@ -174,13 +180,17 @@ func setupApprovalChannelFixture(t *testing.T) approvalChannelFixture { } return approvalChannelFixture{ - db: db, - channelA: channelA, - channelB: channelB, - requestA: insertRequest(channelA, 1), - requestB: insertRequest(channelB, 2), - userID: userID, - customerID: customerID, + db: db, + channelA: channelA, + channelB: channelB, + requestA: insertRequest(channelA, 1), + requestB: insertRequest(channelB, 2), + userID: userID, + customerID: customerID, + workspaceID: workspaceID, + statusID: statusID, + approvalSetStatusID: approvalSetStatusID, + approvalStepID: approvalStepID, } } @@ -320,3 +330,132 @@ func TestPortalServedWorkspaceHonoursPortalConfig(t *testing.T) { t.Fatalf("PortalServesWorkspace(unserved) = %v, %v; want false", unserved, err) } } + +// insertMailbox creates an inbound mailbox channel that can host intakes. +func (fx approvalChannelFixture) insertMailbox(t *testing.T, name string) int { + t.Helper() + var id int + if err := fx.db.QueryRow(` + INSERT INTO channels (name, type, direction, status) + VALUES (?, 'imap', 'inbound', 'enabled') RETURNING id + `, name).Scan(&id); err != nil { + t.Fatalf("insert mailbox: %v", err) + } + return id +} + +// insertIntake links a folder on the mailbox to a routing workspace and, +// when portalChannelID is non-nil, to the portal that exposes its requests. +func (fx approvalChannelFixture) insertIntake(t *testing.T, mailboxID int, portalChannelID *int, folder string) int { + t.Helper() + var id int + if err := fx.db.QueryRow(` + INSERT INTO intakes (mailbox_id, folder, workspace_id, portal_channel_id, status) + VALUES (?, ?, ?, ?, 'enabled') RETURNING id + `, mailboxID, folder, fx.workspaceID, portalChannelID).Scan(&id); err != nil { + t.Fatalf("insert intake: %v", err) + } + return id +} + +// insertIntakeRequest creates an item attributed to the mailbox channel (as +// the email processor does) with an optional linked intake, then opens a +// pending approval with the shared customer in the active pool. +func (fx approvalChannelFixture) insertIntakeRequest(t *testing.T, mailboxID int, intakeID *int, itemNumber int) int { + t.Helper() + itemID64, err := CreateItem(fx.db, ItemCreationParams{ + WorkspaceID: fx.workspaceID, + Title: fmt.Sprintf("Intake request %d", itemNumber), + StatusID: &fx.statusID, + ChannelID: &mailboxID, + IntakeID: intakeID, + CreatorID: &fx.userID, + CreatorPortalCustomerID: &fx.customerID, + }) + if err != nil { + t.Fatalf("insert intake item: %v", err) + } + var requestID int + if err := fx.db.QueryRow(` + INSERT INTO approval_requests + (item_id, approval_set_status_id, status_id, triggered_by_user_id, status) + VALUES (?, ?, ?, ?, 'pending') RETURNING id + `, int(itemID64), fx.approvalSetStatusID, fx.statusID, fx.userID).Scan(&requestID); err != nil { + t.Fatalf("insert intake approval request: %v", err) + } + var stepInstanceID int + if err := fx.db.QueryRow(` + INSERT INTO approval_step_instances + (approval_request_id, approval_step_id, display_order, status, started_at) + VALUES (?, ?, 0, 'pending', CURRENT_TIMESTAMP) RETURNING id + `, requestID, fx.approvalStepID).Scan(&stepInstanceID); err != nil { + t.Fatalf("insert intake step instance: %v", err) + } + if _, err := fx.db.ExecWrite(` + INSERT INTO approval_step_approvers (approval_step_instance_id, portal_customer_id, is_active) + VALUES (?, ?, true) + `, stepInstanceID, fx.customerID); err != nil { + t.Fatalf("insert intake customer approver: %v", err) + } + return requestID +} + +// TestApprovalQueriesIncludeIntakeLinkedRequests covers the portal request +// scope: an item created by an enabled intake linked to the portal is visible +// through the portal even though its channel_id points at the mailbox. Approval +// reads must use the same scope, or an approver cannot see (or decide) a +// request that arrived by email. +func TestApprovalQueriesIncludeIntakeLinkedRequests(t *testing.T) { + fx := setupApprovalChannelFixture(t) + ctx := context.Background() + repo := repository.NewApprovalRepository(fx.db) + + mailboxID := fx.insertMailbox(t, "Support mailbox") + linkedIntake := fx.insertIntake(t, mailboxID, &fx.channelA, "linked") + siblingIntake := fx.insertIntake(t, mailboxID, &fx.channelB, "sibling") + + linkedRequest := fx.insertIntakeRequest(t, mailboxID, &linkedIntake, 10) + siblingRequest := fx.insertIntakeRequest(t, mailboxID, &siblingIntake, 11) + // A mailbox item with no intake is an internal feed, never a portal request. + internalRequest := fx.insertIntakeRequest(t, mailboxID, nil, 12) + + gotA, err := repo.FindRequestIDsForActorInChannel(ctx, "portal_customer_id", fx.customerID, "pending", fx.channelA) + if err != nil { + t.Fatalf("channel A query: %v", err) + } + if !containsInt(gotA, linkedRequest) { + t.Fatalf("channel A requests = %v, want intake-linked request %d", gotA, linkedRequest) + } + if containsInt(gotA, siblingRequest) { + t.Fatalf("channel A requests = %v, must not include sibling intake request %d", gotA, siblingRequest) + } + if containsInt(gotA, internalRequest) { + t.Fatalf("channel A requests = %v, must not include internal mailbox request %d", gotA, internalRequest) + } + + if _, err := repo.FindFullRequestByIDInChannel(ctx, linkedRequest, fx.channelA); err != nil { + t.Fatalf("intake-linked detail: %v", err) + } + if _, err := repo.FindFullRequestByIDInChannel(ctx, siblingRequest, fx.channelA); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("sibling intake detail error = %v, want ErrNotFound", err) + } + if _, err := repo.FindFullRequestByIDInChannel(ctx, internalRequest, fx.channelA); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("internal mailbox detail error = %v, want ErrNotFound", err) + } + + active, err := repo.ActorHasActivePoolMembershipOnItem(ctx, "portal_customer_id", fx.customerID, intRequestItemID(t, fx.db, linkedRequest), &fx.channelA) + if err != nil || !active { + t.Fatalf("active pool membership on intake item = %v, %v; want true", active, err) + } +} + +// intRequestItemID resolves the item behind an approval request for the +// active-pool assertion. +func intRequestItemID(t *testing.T, db database.Database, requestID int) int { + t.Helper() + var itemID int + if err := db.QueryRow(`SELECT item_id FROM approval_requests WHERE id = ?`, requestID).Scan(&itemID); err != nil { + t.Fatalf("load request item: %v", err) + } + return itemID +} diff --git a/internal/services/asset_domain_events_test.go b/internal/services/asset_domain_events_test.go index 445cda1..1aab0dd 100644 --- a/internal/services/asset_domain_events_test.go +++ b/internal/services/asset_domain_events_test.go @@ -111,12 +111,12 @@ func TestAssetMutationLifecycleRecordsOrderedCanonicalFacts(t *testing.T) { gotSequences = append(gotSequences, sequence) } wantTypes := []string{ - assetevents.Created, DurableAssetActionCompatibilityEvent, - assetevents.StatusChanged, DurableAssetActionCompatibilityEvent, - assetevents.Updated, DurableAssetActionCompatibilityEvent, - assetevents.Deleted, DurableAssetActionCompatibilityEvent, + assetevents.Created, + assetevents.StatusChanged, + assetevents.Updated, + assetevents.Deleted, } - wantSequences := []int64{1, 2, 3, 4, 5, 6, 7, 8} + wantSequences := []int64{1, 2, 3, 4} if !reflect.DeepEqual(gotTypes, wantTypes) || !reflect.DeepEqual(gotSequences, wantSequences) { t.Fatalf("asset facts = %v sequences %v, want %v / %v", gotTypes, gotSequences, wantTypes, wantSequences) } @@ -128,7 +128,7 @@ func TestAssetMutationStopsCompatibilityDualWriteAfterCutover(t *testing.T) { db := tdb.GetDatabase() userID, setID, typeID := assetEventFixture(t, db) service := NewAssetService(db, repository.NewAssetRepository(db)) - if _, err := service.compatibilityEvents().ActivateCanonicalAssets(context.Background()); err != nil { + if _, err := NewDurableAssetActionIngress(db).ActivateCanonicalAssets(context.Background()); err != nil { t.Fatalf("activate asset cutover: %v", err) } if _, err := service.CreateAsset(AuditActor{UserID: userID}, repository.CreateAssetInput{ @@ -140,7 +140,7 @@ func TestAssetMutationStopsCompatibilityDualWriteAfterCutover(t *testing.T) { if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", assetevents.Created).Scan(&canonical); err != nil { t.Fatalf("count canonical facts: %v", err) } - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableAssetActionCompatibilityEvent).Scan(&compatibility); err != nil { + if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableAssetActionRequestedEvent).Scan(&compatibility); err != nil { t.Fatalf("count compatibility facts: %v", err) } if canonical != 1 || compatibility != 0 { diff --git a/internal/services/asset_import_automation_test.go b/internal/services/asset_import_automation_test.go index 588fbdd..0ff6ec4 100644 --- a/internal/services/asset_import_automation_test.go +++ b/internal/services/asset_import_automation_test.go @@ -4,16 +4,18 @@ package services import ( "context" - "strings" "testing" "windshift/internal/assetevents" "windshift/internal/events" - "windshift/internal/models" + "windshift/internal/itemevents" "windshift/internal/testutils" ) -func TestImportedAssetRowCreatesAutomationExecutionLog(t *testing.T) { +// TestImportedAssetRowDoesNotCreateAutomationExecutionLog asserts asset imports +// are historical: the canonical created fact carries import provenance and the +// durable consumer refuses to run automation for it. +func TestImportedAssetRowDoesNotCreateAutomationExecutionLog(t *testing.T) { tdb := testutils.CreateTestDB(t, true) t.Cleanup(func() { tdb.Close() }) db := tdb.GetDatabase() @@ -40,6 +42,7 @@ func TestImportedAssetRowCreatesAutomationExecutionLog(t *testing.T) { t.Fatalf("importAssetCSVRow: %v", err) } actions.InvalidateSetCache(setID) + var eventID int64 if err := db.QueryRow("SELECT id FROM domain_events WHERE event_type = ? ORDER BY id DESC LIMIT 1", assetevents.Created).Scan(&eventID); err != nil { t.Fatalf("load event: %v", err) @@ -48,19 +51,18 @@ func TestImportedAssetRowCreatesAutomationExecutionLog(t *testing.T) { if err != nil { t.Fatalf("load event: %v", err) } + if !itemevents.IsImportOrigin(event.ActorKind, event.SourceKind) { + t.Fatalf("imported asset event origin = (%q, %q), want import", event.ActorKind, event.SourceKind) + } if err := NewDurableAssetActionConsumer(db, actions).Handle(context.Background(), *event); err != nil { t.Fatalf("consume event: %v", err) } - var status models.ActionExecutionStatus - var triggerEvent, trace string - var actorID int - if err := db.QueryRow(`SELECT l.status, l.trigger_event, l.execution_trace, a.created_by FROM asset_action_execution_logs l JOIN assets a ON a.id = l.asset_id WHERE l.action_id = ?`, actionID).Scan(&status, &triggerEvent, &trace, &actorID); err != nil { - t.Fatalf("load execution: %v", err) - } - if status != models.ActionStatusCompleted || triggerEvent != string(models.AssetTriggerAssetCreated) || actorID != userID { - t.Fatalf("execution = status %q, event %q, actor %d", status, triggerEvent, actorID) + + var executions int + if err := db.QueryRow(`SELECT COUNT(*) FROM asset_action_execution_logs WHERE action_id = ?`, actionID).Scan(&executions); err != nil { + t.Fatalf("count executions: %v", err) } - if !strings.Contains(trace, `"condition_result":true`) || !strings.Contains(trace, `"field_value":"Imported async"`) { - t.Fatalf("execution trace = %s", trace) + if executions != 0 { + t.Fatalf("imported asset created %d automation executions, want 0", executions) } } diff --git a/internal/services/binding_activation_race_test.go b/internal/services/binding_activation_race_test.go new file mode 100644 index 0000000..9eb4ebb --- /dev/null +++ b/internal/services/binding_activation_race_test.go @@ -0,0 +1,87 @@ +//go:build test + +package services + +import ( + "context" + "errors" + "testing" + + "windshift/internal/models" +) + +func createActivatableProfile(t *testing.T, st *bindingTestStack) *models.WorkspaceAgentBinding { + t.Helper() + llmConnectionID := validTestLLMConnectionID + profile, err := st.BS.CreateStudioProfile(context.Background(), CreateStudioProfileRequest{ + WorkspaceID: 1, + CreatedByUserID: st.AdminID, + TemplateKey: "workspace_guide", + Name: "Workspace Guide", + Handle: "workspace-guide", + Purpose: "Help members find the right workspace context.", + LLMConnectionID: &llmConnectionID, + }) + if err != nil { + t.Fatalf("create Studio profile: %v", err) + } + return profile +} + +// WI-1803: activation must not mark a configuration Ready using validation of +// an older definition. A model change after validation bumps the profile +// version, so activation with the stale validation is refused and the profile +// stays Draft. +func TestActivateStudioProfileRejectsValidationOfStaleDefinition(t *testing.T) { + ctx := context.Background() + st := newBindingTestStack(t, false) + st.BS.llmRuntime = &fakeLLMRuntime{valid: map[int]bool{validTestLLMConnectionID: true, 2: true}} + profile := createActivatableProfile(t, st) + + validation, err := st.BS.ValidateStudioProfile(ctx, 1, profile.ID) + if err != nil { + t.Fatalf("validate: %v", err) + } + if !validation.Ready { + t.Fatalf("expected a ready validation, got %+v", validation) + } + if validation.ProfileVersion != profile.ProfileVersion { + t.Fatalf("validation version = %d, want the validated definition version %d", validation.ProfileVersion, profile.ProfileVersion) + } + + // A concurrent model change lands between validation and activation. + newLLM := 2 + if err := st.BS.UpdateAgentConfig(ctx, 1, profile.ID, profile.Instructions, nil, nil, &newLLM); err != nil { + t.Fatalf("change connection: %v", err) + } + + if _, _, err := st.BS.activateStudioProfile(ctx, 1, profile.ID, validation); !errors.Is(err, ErrAgentProfileVersionConflict) { + t.Fatalf("stale activation = %v, want ErrAgentProfileVersionConflict", err) + } + reloaded, err := st.Bindings.Get(ctx, profile.ID) + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Lifecycle != models.AgentLifecycleDraft { + t.Fatalf("lifecycle = %q, want draft; the changed definition must not be marked Ready", reloaded.Lifecycle) + } +} + +// A validation captured against the current definition still activates. +func TestActivateStudioProfileActivatesCurrentDefinition(t *testing.T) { + ctx := context.Background() + st := newBindingTestStack(t, false) + profile := createActivatableProfile(t, st) + + validation, err := st.BS.ValidateStudioProfile(ctx, 1, profile.ID) + if err != nil { + t.Fatalf("validate: %v", err) + } + activated, _, err := st.BS.activateStudioProfile(ctx, 1, profile.ID, validation) + if err != nil { + t.Fatalf("activate: %v", err) + } + if activated.Lifecycle != models.AgentLifecycleReady { + t.Fatalf("lifecycle = %q, want ready", activated.Lifecycle) + } +} diff --git a/internal/services/board_view_settings_test.go b/internal/services/board_view_settings_test.go index 0549b66..fc69ebf 100644 --- a/internal/services/board_view_settings_test.go +++ b/internal/services/board_view_settings_test.go @@ -118,6 +118,61 @@ func TestBoardViewSettingsRoundTripAndMerge(t *testing.T) { } } +func TestPatchBoardConfigurationPreservesUntouchedFields(t *testing.T) { + tdb, permissions, seed := convergenceFixture(t, true) + service := NewCollectionApplicationService(tdb.GetDatabase(), permissions) + actor := AuditActor{UserID: seed.UserID} + scope := BoardConfigurationScope{WorkspaceID: &seed.WorkspaceID} + + created, err := service.PutBoardConfiguration(actor, scope, models.BoardConfigurationRequest{ + Columns: []models.BoardColumnRequest{{Name: "In progress", Color: "#123456", StatusIDs: []int{seed.StatusID}}}, + CardFields: []models.ListColumn{{FieldIdentifier: "title", FieldType: "system", DisplayOrder: 0, Width: 4}}, + ViewSettings: boardViewSettingsOverride("board", "list"), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if len(created.Columns) != 1 { + t.Fatalf("created columns = %d, want 1", len(created.Columns)) + } + + patched, err := service.PatchBoardConfiguration(actor, scope, BoardConfigurationPatch{ + ViewSettings: OptionalPatch[models.ViewSettings]{Set: true, Value: *boardViewSettingsReset()}, + }) + if err != nil { + t.Fatalf("patch: %v", err) + } + + // A view-settings-only patch must leave the board's columns and card + // fields alone instead of zeroing them, which is what the full-replace + // path did when the navigation page sent only view_settings. + if len(patched.Columns) != 1 || patched.Columns[0].Name != "In progress" { + t.Fatalf("columns after patch = %+v, want the saved column preserved", patched.Columns) + } + if len(patched.CardFields) != 1 || patched.CardFields[0].FieldIdentifier != "title" { + t.Fatalf("card fields after patch = %+v, want them preserved", patched.CardFields) + } + if patched.ViewSettings == nil || patched.ViewSettings.EnabledViews == nil || len(*patched.ViewSettings.EnabledViews) != 0 { + t.Fatalf("view settings after patch = %+v, want the reset override", patched.ViewSettings) + } + + // A patch on a scope without a stored row creates the configuration. + collection, err := service.Create(actor, models.Collection{Name: "Patch create", WorkspaceID: &seed.WorkspaceID, QLQuery: ""}) + if err != nil { + t.Fatalf("create collection: %v", err) + } + createdByPatch, err := service.PatchBoardConfiguration(actor, BoardConfigurationScope{CollectionID: &collection.ID}, BoardConfigurationPatch{ + ViewSettings: OptionalPatch[models.ViewSettings]{Set: true, Value: *boardViewSettingsOverride("board")}, + }) + if err != nil { + t.Fatalf("patch create: %v", err) + } + if createdByPatch.ID == 0 { + t.Fatal("patch on a scope without a row must create the configuration") + } + assertEnabledViews(t, createdByPatch.ViewSettings, []string{"board"}) +} + func TestCollectionScopeInheritsWorkspaceViewSettings(t *testing.T) { tdb, permissions, seed := convergenceFixture(t, true) service := NewCollectionApplicationService(tdb.GetDatabase(), permissions) @@ -291,6 +346,38 @@ func TestPutBoardConfigurationValidatesCompletedVisibility(t *testing.T) { } } +func TestPutBoardConfigurationValidatesCompletedLimit(t *testing.T) { + tdb, permissions, seed := convergenceFixture(t, true) + service := NewCollectionApplicationService(tdb.GetDatabase(), permissions) + actor := AuditActor{UserID: seed.UserID} + scope := BoardConfigurationScope{WorkspaceID: &seed.WorkspaceID} + + for _, value := range []int{0, -1, models.MaxCompletedLimit + 1} { + v := value + t.Run(fmt.Sprintf("rejects %d", value), func(t *testing.T) { + _, err := service.PutBoardConfiguration(actor, scope, models.BoardConfigurationRequest{ + ViewSettings: &models.ViewSettings{CompletedLimit: &v}, + }) + want := fmt.Sprintf("completed_limit must be between %d and %d", models.MinCompletedLimit, models.MaxCompletedLimit) + if err == nil || err.Error() != want { + t.Fatalf("PutBoardConfiguration(%d) error = %v, want %q", value, err, want) + } + }) + } + + for _, value := range []int{models.MinCompletedLimit, models.DefaultCompletedLimit, models.MaxCompletedLimit} { + v := value + t.Run(fmt.Sprintf("accepts %d", value), func(t *testing.T) { + _, err := service.PutBoardConfiguration(actor, scope, models.BoardConfigurationRequest{ + ViewSettings: &models.ViewSettings{CompletedLimit: &v}, + }) + if err != nil { + t.Fatalf("PutBoardConfiguration(%d): %v", value, err) + } + }) + } +} + func TestCollectionScopeInheritsCompletedVisibility(t *testing.T) { tdb, permissions, seed := convergenceFixture(t, true) service := NewCollectionApplicationService(tdb.GetDatabase(), permissions) @@ -306,8 +393,9 @@ func TestCollectionScopeInheritsCompletedVisibility(t *testing.T) { workspaceScope := BoardConfigurationScope{WorkspaceID: &seed.WorkspaceID} workspaceVisibility := "last_week" + workspaceLimit := 40 if _, err := service.PutBoardConfiguration(actor, workspaceScope, models.BoardConfigurationRequest{ - ViewSettings: &models.ViewSettings{CompletedVisibility: &workspaceVisibility}, + ViewSettings: &models.ViewSettings{CompletedVisibility: &workspaceVisibility, CompletedLimit: &workspaceLimit}, }); err != nil { t.Fatalf("workspace override: %v", err) } @@ -320,6 +408,9 @@ func TestCollectionScopeInheritsCompletedVisibility(t *testing.T) { *config.ViewSettings.CompletedVisibility != "last_week" { t.Fatalf("inherited completed visibility = %+v, want last_week", config.ViewSettings) } + if config.ViewSettings.CompletedLimit == nil || *config.ViewSettings.CompletedLimit != 40 { + t.Fatalf("inherited completed limit = %+v, want 40", config.ViewSettings) + } // A collection override wins over the workspace default. collectionVisibility := "none" @@ -336,4 +427,8 @@ func TestCollectionScopeInheritsCompletedVisibility(t *testing.T) { *config.ViewSettings.CompletedVisibility != "none" { t.Fatalf("collection completed visibility = %+v, want none", config.ViewSettings) } + // The cap still inherits even when the collection picks a different preset. + if config.ViewSettings.CompletedLimit == nil || *config.ViewSettings.CompletedLimit != 40 { + t.Fatalf("collection completed limit = %+v, want inherited 40", config.ViewSettings) + } } diff --git a/internal/services/catalog_workspace_list_test.go b/internal/services/catalog_workspace_list_test.go index 8cd1712..973a5cf 100644 --- a/internal/services/catalog_workspace_list_test.go +++ b/internal/services/catalog_workspace_list_test.go @@ -448,3 +448,84 @@ func TestWorkspaceServiceList_SearchContract(t *testing.T) { } }) } + +// TestCatalogListWorkspaces_IDsResolve covers the direct id-set read: the +// directory page cache holds only the first server page, so callers that need +// a known set resolve it in one query instead of one query per id. +func TestCatalogListWorkspaces_IDsResolve(t *testing.T) { + fx := newCatalogListFixture(t) + first := fx.createWorkspace(t, "Alpha", "ALPHA", true, false) + second := fx.createWorkspace(t, "Beta", "BETA", true, false) + hidden := fx.createWorkspace(t, "Hidden", "HID", true, false) + other := fx.createWorkspace(t, "Gamma", "GAMMA", true, false) + fx.grantView(first) + fx.grantView(second) + fx.grantView(other) + // hidden has no grant and must never appear in an id resolve. + + svc := fx.service(t) + + t.Run("returnsExactlyTheRequestedVisibleSet", func(t *testing.T) { + rows, total, err := svc.ListWorkspaces(fx.userID, services.CatalogPageParams{IDs: []int{second, first}}) + if err != nil { + t.Fatalf("ListWorkspaces ids: %v", err) + } + if total != 2 || len(rows) != 2 { + t.Fatalf("got %d rows total %d, want 2/2", len(rows), total) + } + got := map[int]bool{} + for _, ws := range rows { + got[ws.ID] = true + } + if !got[first] || !got[second] { + t.Fatalf("requested workspaces missing: %+v", rows) + } + if got[other] { + t.Fatalf("unrequested workspace %d leaked into the id resolve", other) + } + }) + + t.Run("dropsIdsTheCallerCannotView", func(t *testing.T) { + rows, total, err := svc.ListWorkspaces(fx.userID, services.CatalogPageParams{IDs: []int{first, hidden}}) + if err != nil { + t.Fatalf("ListWorkspaces ids: %v", err) + } + if total != 1 || len(rows) != 1 || rows[0].ID != first { + t.Fatalf("got %+v total %d, want only workspace %d", rows, total, first) + } + }) + + t.Run("returnsEveryRequestedRowRegardlessOfLimit", func(t *testing.T) { + rows, total, err := svc.ListWorkspaces(fx.userID, services.CatalogPageParams{ + IDs: []int{first, second, other}, Limit: 1, + }) + if err != nil { + t.Fatalf("ListWorkspaces ids: %v", err) + } + if total != 3 || len(rows) != 3 { + t.Fatalf("got %d rows total %d, want 3/3", len(rows), total) + } + }) + + t.Run("idsTakePrecedenceOverSearch", func(t *testing.T) { + rows, total, err := svc.ListWorkspaces(fx.userID, services.CatalogPageParams{ + IDs: []int{first}, Search: "Beta", + }) + if err != nil { + t.Fatalf("ListWorkspaces ids: %v", err) + } + if total != 1 || len(rows) != 1 || rows[0].ID != first { + t.Fatalf("search was not ignored for an id resolve: %+v total %d", rows, total) + } + }) + + t.Run("unknownIdsReturnAnEmptyPage", func(t *testing.T) { + rows, total, err := svc.ListWorkspaces(fx.userID, services.CatalogPageParams{IDs: []int{999999}}) + if err != nil { + t.Fatalf("ListWorkspaces ids: %v", err) + } + if total != 0 || len(rows) != 0 { + t.Fatalf("unknown ids returned %d rows total %d, want empty/0", len(rows), total) + } + }) +} diff --git a/internal/services/catalog_workspace_visibility_test.go b/internal/services/catalog_workspace_visibility_test.go index 2322739..153bb0a 100644 --- a/internal/services/catalog_workspace_visibility_test.go +++ b/internal/services/catalog_workspace_visibility_test.go @@ -97,7 +97,7 @@ func listVisibleWorkspaceIDs(t *testing.T, catalog *CatalogReadService, userID i return ids, total } -func TestCatalogListWorkspacesDecodesSnapshotOncePerRequest(t *testing.T) { +func TestCatalogListWorkspacesReusesSnapshotWithoutDecodePerRequest(t *testing.T) { env, catalog := newCatalogPermissionEnv(t) readerID := env.insertUser("directory-reader@example.test") gaterID := env.insertUser("directory-gater@example.test") @@ -128,15 +128,19 @@ func TestCatalogListWorkspacesDecodesSnapshotOncePerRequest(t *testing.T) { } hiddenID := ids[seeded-1] - // Warm the user's permission byte cache: one build plus one decode. + // Warm the user's permission snapshot so the request below is a cache hit. if _, err := env.service.AccessibleWorkspaceIDs(readerID); err != nil { t.Fatalf("warm permission snapshot: %v", err) } - before := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before := env.service.GetCacheStats() visible, total := listVisibleWorkspaceIDs(t, catalog, readerID, 1000) - if decodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before; decodes != 1 { - t.Fatalf("permission snapshot decodes for one warm directory request = %d, want 1", decodes) + after := env.service.GetCacheStats() + if decodes := after.PermissionSnapshotDecodes - before.PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission snapshot decodes for one warm directory request = %d, want 0", decodes) + } + if hits := after.Hits - before.Hits; hits != 1 { + t.Fatalf("permission cache hits for one warm directory request = %d, want 1", hits) } if total != wantTotal { t.Fatalf("total = %d, want %d", total, wantTotal) @@ -151,9 +155,8 @@ func TestCatalogListWorkspacesDecodesSnapshotOncePerRequest(t *testing.T) { } // Revocation must stay effective through the request-scoped snapshot. - // After invalidation the next request rebuilds the byte cache (a build - // returns the snapshot directly, with no decode), and the request after - // that decodes the rebuilt snapshot exactly once. + // After invalidation the next request rebuilds the typed cache and the + // request after that is served from it with no decode. if _, err := repository.NewWorkspaceRoleRepository(env.db).RevokeFromUser(readerID, ids[0], viewerRoleID); err != nil { t.Fatalf("revoke reader role: %v", err) } @@ -167,12 +170,16 @@ func TestCatalogListWorkspacesDecodesSnapshotOncePerRequest(t *testing.T) { if total != wantTotal-1 { t.Fatalf("total after revocation = %d, want %d", total, wantTotal-1) } - before = env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before = env.service.GetCacheStats() if _, total = listVisibleWorkspaceIDs(t, catalog, readerID, 1000); total != wantTotal-1 { t.Fatalf("total on steady-state request = %d, want %d", total, wantTotal-1) } - if decodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before; decodes != 1 { - t.Fatalf("permission snapshot decodes for steady-state directory request = %d, want 1", decodes) + after = env.service.GetCacheStats() + if decodes := after.PermissionSnapshotDecodes - before.PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission snapshot decodes for steady-state directory request = %d, want 0", decodes) + } + if hits := after.Hits - before.Hits; hits != 1 { + t.Fatalf("permission cache hits for steady-state directory request = %d, want 1", hits) } } @@ -311,7 +318,7 @@ func BenchmarkCatalogListWorkspaces10kWorkspaces(b *testing.B) { b.Fatalf("warm directory request: %v", err) } - before := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before := env.service.GetCacheStats().Hits b.ResetTimer() for i := 0; i < b.N; i++ { rows, total, err := catalog.ListWorkspaces(readerID, page) @@ -322,7 +329,7 @@ func BenchmarkCatalogListWorkspaces10kWorkspaces(b *testing.B) { b.Fatalf("total = %d rows = %d, want %d/%d", total, len(rows), workspaces, page.Limit) } } - if decodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before; decodes != uint64(b.N) { - b.Fatalf("permission snapshot decodes = %d for %d requests, want one decode per request", decodes, b.N) + if hits := env.service.GetCacheStats().Hits - before; hits != int64(b.N) { + b.Fatalf("permission cache hits = %d for %d requests, want one hit per request", hits, b.N) } } diff --git a/internal/services/comment_service_test.go b/internal/services/comment_service_test.go index ec53793..203f537 100644 --- a/internal/services/comment_service_test.go +++ b/internal/services/comment_service_test.go @@ -431,6 +431,55 @@ func TestCommentService_ImportedWritesBoundSource(t *testing.T) { }) } +// TestCommentService_ImportedCommentSkipsAuditAndActivity asserts an imported +// public agent reply keeps the support first-response fact but does not write a +// live per-comment audit entry, unlike a live reply on the same ticket. +func TestCommentService_ImportedCommentSkipsAuditAndActivity(t *testing.T) { + db := createCommentTestDB(t) + service := services.NewCommentService(db) + env := setupCommentTestEnv(t, db) + + channelID := testutils.InsertID(t, db, `INSERT INTO channels (name, type, direction, status) VALUES ('Email', 'email', 'inbound', 'enabled')`) + if _, err := db.Exec(`UPDATE items SET channel_id = ? WHERE id = ?`, channelID, env.ItemID); err != nil { + t.Fatalf("attach channel: %v", err) + } + + live, err := service.Create(services.CreateCommentParams{ + ItemID: env.ItemID, AuthorID: env.UserID, ActorUserID: env.UserID, Content: "live reply", + }) + if err != nil { + t.Fatalf("create live comment: %v", err) + } + imported, err := service.CreateImported(services.CreateCommentParams{ + ItemID: env.ItemID, AuthorID: env.UserID, ActorUserID: env.UserID, Content: "historical reply", + }) + if err != nil { + t.Fatalf("create imported comment: %v", err) + } + + var liveAudits, importedAudits int + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action_type = 'comment.create' AND resource_id = ?`, live.CommentID).Scan(&liveAudits); err != nil { + t.Fatalf("count live audits: %v", err) + } + if liveAudits != 1 { + t.Fatalf("live comment audit rows = %d, want 1", liveAudits) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action_type = 'comment.create' AND resource_id = ?`, imported.CommentID).Scan(&importedAudits); err != nil { + t.Fatalf("count imported audits: %v", err) + } + if importedAudits != 0 { + t.Fatalf("imported comment wrote %d audit rows, want 0", importedAudits) + } + + var firstResponses int + if err := db.QueryRow(`SELECT COUNT(*) FROM item_support_events WHERE item_id = ? AND kind = 'first_response'`, env.ItemID).Scan(&firstResponses); err != nil { + t.Fatalf("count first responses: %v", err) + } + if firstResponses != 1 { + t.Fatalf("first-response rows = %d, want 1", firstResponses) + } +} + func TestCommentService_Delete(t *testing.T) { db := createCommentTestDB(t) diff --git a/internal/services/customer_data_export_test.go b/internal/services/customer_data_export_test.go index b410568..eea23fb 100644 --- a/internal/services/customer_data_export_test.go +++ b/internal/services/customer_data_export_test.go @@ -46,6 +46,13 @@ func TestCustomerDataExport_CoversAllCategories(t *testing.T) { t.Fatalf("approval status = %s, want approved", out.Status) } actor := AuditActor{UserID: env.approver1, Username: "approver1"} + + // A real definition lets the export resolve the legacy name key to its id + // and expose the human-readable value (WI-2025). + if _, err := env.db.Exec(`INSERT INTO custom_field_definitions (id, name, field_type, applies_to_portal_customers, applies_to_customer_organisations) VALUES (77, 'tier', 'text', true, false)`); err != nil { + t.Fatalf("seed custom field: %v", err) + } + export, err := ExportCustomerData(env.db, customerID, actor, []int{env.workspaceID}) if err != nil { t.Fatalf("ExportCustomerData: %v", err) @@ -65,9 +72,12 @@ func TestCustomerDataExport_CoversAllCategories(t *testing.T) { if len(export.Customer.Roles) != 1 || export.Customer.Roles[0] != "Portal Customer" { t.Errorf("roles = %v, want [Portal Customer]", export.Customer.Roles) } - if string(export.Customer.CustomFieldValues) != `{"tier":"gold"}` { + if string(export.Customer.CustomFieldValues) != `{"77":"gold"}` { t.Errorf("custom_field_values = %s", export.Customer.CustomFieldValues) } + if len(export.Customer.CustomFields) != 1 || export.Customer.CustomFields[0].FieldID != 77 || export.Customer.CustomFields[0].Name != "tier" || export.Customer.CustomFields[0].Value != "gold" { + t.Errorf("custom_fields = %#v", export.Customer.CustomFields) + } // The requested item carries the workspace display key. if len(export.RequestedItems) != 1 { diff --git a/internal/services/customer_notice_test.go b/internal/services/customer_notice_test.go index 2861833..8615ede 100644 --- a/internal/services/customer_notice_test.go +++ b/internal/services/customer_notice_test.go @@ -35,6 +35,10 @@ func (s *fakeThreadedSender) RenderEmail(templateName string, data any) (string, return "subject", "

html

", "text", nil } +func (s *fakeThreadedSender) RenderEmailLocale(_, templateName string, data any) (string, string, string, error) { + return s.RenderEmail(templateName, data) +} + type customerNoticeFixture struct { db database.Database sender *fakeThreadedSender diff --git a/internal/services/docmost_service_test.go b/internal/services/docmost_service_test.go new file mode 100644 index 0000000..859e685 --- /dev/null +++ b/internal/services/docmost_service_test.go @@ -0,0 +1,244 @@ +//go:build test + +package services + +import ( + "context" + "errors" + "net/url" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/integrations/docmost" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +type fakeDocmostClient struct { + registerID string + registerErr error + exchange docmost.Token + exchangeErr error + refresh docmost.Token + refreshErr error + revoked int +} + +func (f *fakeDocmostClient) RegisterClient(context.Context, string, string, string) (string, error) { + return f.registerID, f.registerErr +} + +func (f *fakeDocmostClient) ExchangeCode(context.Context, string, string, string, string, string) (docmost.Token, error) { + return f.exchange, f.exchangeErr +} + +func (f *fakeDocmostClient) RefreshToken(context.Context, string, string, string) (docmost.Token, error) { + return f.refresh, f.refreshErr +} + +func (f *fakeDocmostClient) RevokeToken(context.Context, string, string, string) error { + f.revoked++ + return nil +} + +// newDocmostServiceEnv seeds a Docmost integration provider and a user on the +// production schema and returns the service under test. +func newDocmostServiceEnv(t *testing.T, client DocmostClientAPI) (*DocmostService, database.Database, string) { + t.Helper() + tdb := testutils.CreateTestDB(t, true) + if tdb.Engine == "sqlite" { + t.Cleanup(func() { _ = tdb.Close() }) + } + db := tdb.DB + + providerID := "01a1aaff-0000-0000-0000-000000000001" + if _, err := db.ExecWrite(` + INSERT INTO integration_providers (id, slug, name, provider_type, enabled, provider_config) + VALUES (?, 'docmost', 'Docmost', 'docmost', true, ?) + `, providerID, `{"base_url":"https://wiki.example.com"}`); err != nil { + t.Fatalf("seed provider: %v", err) + } + testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name, password_hash, is_active) VALUES ('u2@test.com', 'u2', 'U', 'Two', 'x', true)`) + + svc := NewDocmostService( + repository.NewIntegrationProviderRepository(db), + repository.NewIntegrationTokenRepository(db), + NewDocmostCredentialEncryption("test-server-secret-with-sufficient-length-for-derivation"), + client, + ) + return svc, db, providerID +} + +func TestDocmostService_StartOAuthRegistersClientAndStoresPKCE(t *testing.T) { + client := &fakeDocmostClient{registerID: "client-123"} + svc, db, providerID := newDocmostServiceEnv(t, client) + + authorizeURL, verifier, err := svc.StartOAuth(context.Background(), providerID, "state-1", "http://windshift.test/api/integrations/oauth/docmost/callback") + if err != nil { + t.Fatalf("start oauth: %v", err) + } + parsed, err := url.Parse(authorizeURL) + if err != nil { + t.Fatalf("parse authorize url: %v", err) + } + if parsed.Path != "/oauth/consent" || parsed.Host != "wiki.example.com" { + t.Fatalf("authorize url = %q", authorizeURL) + } + query := parsed.Query() + if query.Get("client_id") != "client-123" || query.Get("state") != "state-1" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") == "" { + t.Fatalf("authorize query = %v", query) + } + if verifier == "" { + t.Fatal("empty code verifier") + } + + var storedClientID string + if err := db.QueryRow(`SELECT oauth_client_id FROM integration_providers WHERE id = ?`, providerID).Scan(&storedClientID); err != nil { + t.Fatalf("read client id: %v", err) + } + if storedClientID != "client-123" { + t.Fatalf("stored client id = %q", storedClientID) + } +} + +func TestDocmostService_CompleteOAuthStoresEncryptedToken(t *testing.T) { + client := &fakeDocmostClient{ + registerID: "client-123", + exchange: docmost.Token{AccessToken: "oauth-access", RefreshToken: "oauth-refresh", Scope: "read write", ExpiresAt: time.Now().Add(time.Hour)}, + } + svc, db, providerID := newDocmostServiceEnv(t, client) + + if _, _, err := svc.StartOAuth(context.Background(), providerID, "state-1", "http://windshift.test/cb"); err != nil { + t.Fatalf("start oauth: %v", err) + } + if err := svc.CompleteOAuth(context.Background(), providerID, 1, "verifier", "code", "http://windshift.test/cb"); err != nil { + t.Fatalf("complete oauth: %v", err) + } + + var encAccess, encRefresh, authMethod string + var expiresAt time.Time + if err := db.QueryRow(` + SELECT oauth_access_token_encrypted, encrypted_refresh_token, auth_method, expires_at + FROM user_integration_tokens WHERE integration_provider_id = ? AND user_id = ? + `, providerID, "1").Scan(&encAccess, &encRefresh, &authMethod, &expiresAt); err != nil { + t.Fatalf("read token: %v", err) + } + if encAccess == "oauth-access" || encRefresh == "oauth-refresh" { + t.Fatal("tokens stored in plaintext") + } + if authMethod != "oauth" || !expiresAt.After(time.Now()) { + t.Fatalf("auth_method = %q expires_at = %v", authMethod, expiresAt) + } + + _, token, err := svc.Credential(context.Background(), 1) + if err != nil { + t.Fatalf("credential: %v", err) + } + if token != "oauth-access" { + t.Fatalf("token = %q", token) + } +} + +func TestDocmostService_CredentialRefreshesExpiredToken(t *testing.T) { + client := &fakeDocmostClient{ + registerID: "client-123", + refresh: docmost.Token{AccessToken: "fresh-access", RefreshToken: "fresh-refresh", ExpiresAt: time.Now().Add(time.Hour)}, + } + svc, db, providerID := newDocmostServiceEnv(t, client) + if err := svc.providers.Update(providerID, repository.IntegrationProviderUpdate{OAuthClientID: ptr("client-123")}); err != nil { + t.Fatalf("set client id: %v", err) + } + seedExpiredToken(t, svc, db, providerID, 1, "stale-access", "stale-refresh") + + _, token, err := svc.Credential(context.Background(), 1) + if err != nil { + t.Fatalf("credential: %v", err) + } + if token != "fresh-access" { + t.Fatalf("token = %q, want the refreshed token", token) + } +} + +func TestDocmostService_RefreshRejectedMarksReconnect(t *testing.T) { + client := &fakeDocmostClient{registerID: "client-123", refreshErr: docmost.ErrUnauthorized} + svc, db, providerID := newDocmostServiceEnv(t, client) + if err := svc.providers.Update(providerID, repository.IntegrationProviderUpdate{OAuthClientID: ptr("client-123")}); err != nil { + t.Fatalf("set client id: %v", err) + } + seedExpiredToken(t, svc, db, providerID, 1, "stale-access", "stale-refresh") + + if _, _, err := svc.Credential(context.Background(), 1); !errors.Is(err, ErrDocmostReconnect) { + t.Fatalf("credential error = %v, want ErrDocmostReconnect", err) + } + var invalidated *time.Time + if err := db.QueryRow(`SELECT invalidated_at FROM user_integration_tokens WHERE integration_provider_id = ? AND user_id = ?`, providerID, "1").Scan(&invalidated); err != nil { + t.Fatalf("read invalidated_at: %v", err) + } + if invalidated == nil { + t.Fatal("invalidated_at was not set after a rejected refresh") + } +} + +func TestDocmostService_ReconnectClearsInvalidated(t *testing.T) { + client := &fakeDocmostClient{ + registerID: "client-123", + exchange: docmost.Token{AccessToken: "fresh-access", RefreshToken: "fresh-refresh", ExpiresAt: time.Now().Add(time.Hour)}, + } + svc, db, providerID := newDocmostServiceEnv(t, client) + if err := svc.providers.Update(providerID, repository.IntegrationProviderUpdate{OAuthClientID: ptr("client-123")}); err != nil { + t.Fatalf("set client id: %v", err) + } + seedExpiredToken(t, svc, db, providerID, 1, "stale-access", "stale-refresh") + svc.MarkInvalid(1) + + // Reconnecting runs the OAuth callback again, which upserts the token pair. + if err := svc.CompleteOAuth(context.Background(), providerID, 1, "verifier", "code", "http://windshift.test/cb"); err != nil { + t.Fatalf("complete oauth: %v", err) + } + + var invalidated *time.Time + if err := db.QueryRow(`SELECT invalidated_at FROM user_integration_tokens WHERE integration_provider_id = ? AND user_id = ?`, providerID, "1").Scan(&invalidated); err != nil { + t.Fatalf("read invalidated_at: %v", err) + } + if invalidated != nil { + t.Fatalf("invalidated_at = %v, want NULL after reconnect", invalidated) + } + if _, token, err := svc.Credential(context.Background(), 1); err != nil || token != "fresh-access" { + t.Fatalf("credential after reconnect = %q, %v", token, err) + } +} + +func TestDocmostService_NotConfigured(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + if tdb.Engine == "sqlite" { + t.Cleanup(func() { _ = tdb.Close() }) + } + svc := NewDocmostService( + repository.NewIntegrationProviderRepository(tdb.DB), + repository.NewIntegrationTokenRepository(tdb.DB), + NewDocmostCredentialEncryption("test-server-secret-with-sufficient-length-for-derivation"), + &fakeDocmostClient{}, + ) + if _, _, err := svc.Credential(context.Background(), 1); !errors.Is(err, ErrDocmostNotConfigured) { + t.Fatalf("credential error = %v, want ErrDocmostNotConfigured", err) + } +} + +func seedExpiredToken(t *testing.T, svc *DocmostService, db database.Database, providerID string, userID int, access, refresh string) { + t.Helper() + encAccess, err := svc.encryption.Encrypt(access) + if err != nil { + t.Fatalf("encrypt access: %v", err) + } + encRefresh, err := svc.encryption.Encrypt(refresh) + if err != nil { + t.Fatalf("encrypt refresh: %v", err) + } + past := time.Now().Add(-time.Minute) + if err := svc.tokens.UpsertOAuthToken(providerID, userID, encAccess, encRefresh, "read write", &past); err != nil { + t.Fatalf("seed token: %v", err) + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/services/durable_action_consumer_test.go b/internal/services/durable_action_consumer_test.go index cb27d5a..fc13c5b 100644 --- a/internal/services/durable_action_consumer_test.go +++ b/internal/services/durable_action_consumer_test.go @@ -149,50 +149,87 @@ func TestDurableActionConsumerFreezesTargetsAndDeduplicatesExecution(t *testing. } } -func TestDurableActionIngressCutsItemCompatibilityAtCanonicalBoundary(t *testing.T) { +func TestDurableActionConsumerSkipsImportedFacts(t *testing.T) { tdb := testutils.CreateTestDB(t, true) t.Cleanup(func() { _ = tdb.Close() }) data := tdb.SeedTestData(t) db := tdb.GetDatabase() - ingress := NewDurableActionIngress(db) - ctx := context.Background() + itemID64, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "imported target item", + StatusID: &data.StatusID, + CreatorID: &data.UserID, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } - legacy := &models.ActionEvent{ - EventType: models.ActionTriggerItemUpdated, WorkspaceID: data.WorkspaceID, - ItemID: 17, ActorUserID: data.UserID, NewValues: map[string]any{"title": "before"}, + repo := repository.NewActionRepository(db) + createDurableTestAction(t, repo, data.WorkspaceID, "imported", models.ActionTriggerItemCreated) + service := NewActionService(db, DefaultActionServiceConfig(), nil) + t.Cleanup(service.Stop) + service.InvalidateWorkspaceCache(data.WorkspaceID) + consumer := NewDurableActionConsumer(db, service) + event := durableCreatedEvent(t, 42, "item-created-import-42", data.WorkspaceID, int(itemID64), data.UserID) + event.ActorKind, event.ActorRef, event.SourceKind = "import", "jira-job-1", "import" + + if err := consumer.Handle(context.Background(), event); err != nil { + t.Fatalf("Handle() error = %v", err) } - if err := ingress.Emit(ctx, legacy); err != nil { - t.Fatalf("Emit() before cutover error = %v", err) + var batchCount, targetCount, executionCount int + if err := db.QueryRow("SELECT COUNT(*) FROM action_event_batches WHERE event_key = ?", event.Key).Scan(&batchCount); err != nil { + t.Fatalf("count materialization batches: %v", err) } - cutover, err := ingress.ActivateCanonicalItems(ctx) - if err != nil { - t.Fatalf("ActivateCanonicalItems() error = %v", err) + if err := db.QueryRow("SELECT COUNT(*) FROM action_event_targets WHERE event_key = ?", event.Key).Scan(&targetCount); err != nil { + t.Fatalf("count durable targets: %v", err) + } + if err := db.QueryRow("SELECT COUNT(*) FROM action_execution_logs WHERE durable_event_key = ?", event.Key).Scan(&executionCount); err != nil { + t.Fatalf("count durable executions: %v", err) } - if cutover.StartEventID <= 1 { - t.Fatalf("cutover start event = %d, want after compatibility event", cutover.StartEventID) + if batchCount != 0 || targetCount != 0 || executionCount != 0 { + t.Fatalf("imported fact rows = batches:%d targets:%d executions:%d, want 0/0/0", batchCount, targetCount, executionCount) } - legacy.NewValues["title"] = "after" - if err := ingress.Emit(ctx, legacy); err != nil { - t.Fatalf("Emit() after cutover error = %v", err) +} + +func TestDurableActionIngressNeverJournalsItemTriggers(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + ingress := NewDurableActionIngress(db) + ctx := context.Background() + + // Item triggers are derived from canonical item facts, so the synchronous + // legacy shape must never be journaled. + if err := ingress.Emit(ctx, &models.ActionEvent{ + EventType: models.ActionTriggerItemUpdated, WorkspaceID: data.WorkspaceID, + ItemID: 17, ActorUserID: data.UserID, NewValues: map[string]any{"title": "before"}, + }); err != nil { + t.Fatalf("Emit() item trigger error = %v", err) } + // SCM observations stay durable. if err := ingress.Emit(ctx, &models.ActionEvent{ EventType: models.ActionTriggerSCMTagCreated, WorkspaceID: data.WorkspaceID, NewValues: map[string]any{"ref.name": "v0.8.8", "ref.sha": "abc123", "repo.workspace_repository_id": 9}, }); err != nil { - t.Fatalf("Emit() SCM event after cutover error = %v", err) + t.Fatalf("Emit() SCM event error = %v", err) } - var compatibilityEvents, scmEvents int - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableActionCompatibilityEvent).Scan(&compatibilityEvents); err != nil { - t.Fatalf("count compatibility events: %v", err) + var sideEffectEvents, scmEvents int + if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableActionRequestedEvent).Scan(&sideEffectEvents); err != nil { + t.Fatalf("count side-effect events: %v", err) } if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableSCMObservationEvent).Scan(&scmEvents); err != nil { t.Fatalf("count SCM events: %v", err) } - if compatibilityEvents != 1 || scmEvents != 1 { - t.Fatalf("durable ingress events = compatibility:%d scm:%d, want 1/1", compatibilityEvents, scmEvents) + if sideEffectEvents != 0 || scmEvents != 1 { + t.Fatalf("durable ingress events = side_effect:%d scm:%d, want 0/1", sideEffectEvents, scmEvents) } + cutover, err := ingress.ActivateCanonicalItems(ctx) + if err != nil { + t.Fatalf("ActivateCanonicalItems() error = %v", err) + } store := events.NewStore(db) if err := ConfigureDurableActionConsumers(ctx, store, cutover); err != nil { t.Fatalf("ConfigureDurableActionConsumers() error = %v", err) diff --git a/internal/services/durable_asset_action_consumer_test.go b/internal/services/durable_asset_action_consumer_test.go index a9d2289..0dc2efe 100644 --- a/internal/services/durable_asset_action_consumer_test.go +++ b/internal/services/durable_asset_action_consumer_test.go @@ -61,35 +61,17 @@ func TestDurableAssetActionConsumerUsesSharedFrozenTargetsAndDeduplication(t *te } } -func TestDurableAssetIngressCutsCompatibilityAtCanonicalBoundary(t *testing.T) { +func TestDurableAssetConsumersAnchorAtCanonicalBoundary(t *testing.T) { tdb := testutils.CreateTestDB(t, true) t.Cleanup(func() { _ = tdb.Close() }) db := tdb.GetDatabase() - userID, setID, _, assetID := durableAssetFixture(t, db) ingress := NewDurableAssetActionIngress(db) ctx := context.Background() - legacy := &models.AssetActionEvent{ - EventType: models.AssetTriggerAssetUpdated, SetID: setID, AssetID: assetID, - ActorUserID: userID, NewValues: map[string]any{"title": "before"}, - } - if err := ingress.Emit(ctx, legacy); err != nil { - t.Fatalf("Emit() before cutover error = %v", err) - } + cutover, err := ingress.ActivateCanonicalAssets(ctx) if err != nil { t.Fatalf("ActivateCanonicalAssets() error = %v", err) } - legacy.NewValues["title"] = "after" - if err := ingress.Emit(ctx, legacy); err != nil { - t.Fatalf("Emit() after cutover error = %v", err) - } - var compatibilityEvents int - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableAssetActionCompatibilityEvent).Scan(&compatibilityEvents); err != nil { - t.Fatalf("count asset compatibility events: %v", err) - } - if compatibilityEvents != 1 { - t.Fatalf("asset compatibility events = %d, want 1", compatibilityEvents) - } if err := ConfigureDurableAssetActionConsumers(ctx, events.NewStore(db), cutover); err != nil { t.Fatalf("ConfigureDurableAssetActionConsumers() error = %v", err) } @@ -128,10 +110,10 @@ func TestSharedDurableEngineProcessesMixedItemAndAssetActions(t *testing.T) { config.HandlerTimeout = time.Second config.LeaseDuration = 2 * time.Second engine := events.NewEngine(db, config) - if err := PrepareDurableActionEngine(context.Background(), engine, itemActions, true); err != nil { + if err := PrepareDurableActionEngine(context.Background(), engine, itemActions); err != nil { t.Fatalf("prepare item consumer: %v", err) } - if err := PrepareDurableAssetActionEngine(context.Background(), engine, assetActions, true); err != nil { + if err := PrepareDurableAssetActionEngine(context.Background(), engine, assetActions); err != nil { t.Fatalf("prepare asset consumer: %v", err) } if err := engine.Start(context.Background()); err != nil { @@ -204,10 +186,10 @@ func TestDurableAssetActionEngineProcessesEventsWrittenWhileStoppedAfterRestart( config.PollInterval = 5 * time.Millisecond config.HandlerTimeout = time.Second config.LeaseDuration = 2 * time.Second - startEngine := func(activate bool) *events.Engine { + startEngine := func() *events.Engine { t.Helper() engine := events.NewEngine(db, config) - if err := PrepareDurableAssetActionEngine(context.Background(), engine, actions, activate); err != nil { + if err := PrepareDurableAssetActionEngine(context.Background(), engine, actions); err != nil { t.Fatalf("prepare asset consumer: %v", err) } if err := engine.Start(context.Background()); err != nil { @@ -224,7 +206,7 @@ func TestDurableAssetActionEngineProcessesEventsWrittenWhileStoppedAfterRestart( } } - firstEngine := startEngine(true) + firstEngine := startEngine() if _, err := assets.CreateAsset(AuditActor{UserID: userID}, repository.CreateAssetInput{ SetID: setID, AssetTypeID: typeID, Title: "Before restart", AssetTag: "AST-R1", CreatedBy: userID, CreatedAt: time.Now().UTC(), }, nil); err != nil { @@ -246,38 +228,11 @@ func TestDurableAssetActionEngineProcessesEventsWrittenWhileStoppedAfterRestart( t.Fatalf("executions while stopped = %d, want 1", executionsWhileStopped) } - secondEngine := startEngine(false) + secondEngine := startEngine() t.Cleanup(func() { stopEngine(secondEngine) }) waitForDurableAssetExecutions(t, db, 2) } -func TestDurableAssetIngressDoesNotDropEventsAtLegacyBufferCapacity(t *testing.T) { - tdb := testutils.CreateTestDB(t, true) - t.Cleanup(func() { _ = tdb.Close() }) - db := tdb.GetDatabase() - userID, setID, _, assetID := durableAssetFixtureWithEmail(t, db, "durable-saturation@example.test") - config := DefaultActionServiceConfig() - config.EventBufferSize = 1 - service := NewAssetActionService(db, config, nil) - t.Cleanup(service.Stop) - - const eventCount = 32 - for index := 0; index < eventCount; index++ { - service.EmitAssetActionEvent(&models.AssetActionEvent{ - EventType: models.AssetTriggerAssetUpdated, - SetID: setID, AssetID: assetID, ActorUserID: userID, - NewValues: map[string]any{"sequence": index}, - }) - } - var persisted int - if err := db.QueryRow("SELECT COUNT(*) FROM domain_events WHERE event_type = ?", DurableAssetActionCompatibilityEvent).Scan(&persisted); err != nil { - t.Fatalf("count saturated compatibility events: %v", err) - } - if persisted != eventCount { - t.Fatalf("persisted compatibility events = %d, want %d", persisted, eventCount) - } -} - func waitForDurableAssetExecutions(t *testing.T, db interface { QueryRow(string, ...any) *sql.Row }, want int) { diff --git a/internal/services/durable_email_reply_consumer_test.go b/internal/services/durable_email_reply_consumer_test.go index 63edb8f..5b3175c 100644 --- a/internal/services/durable_email_reply_consumer_test.go +++ b/internal/services/durable_email_reply_consumer_test.go @@ -55,6 +55,10 @@ func (s *durableReplySender) RenderEmail(string, any) (string, string, string, e return "rendered subject", "rendered html", "rendered text", nil } +func (s *durableReplySender) RenderEmailLocale(_, templateName string, data any) (string, string, string, error) { + return s.RenderEmail(templateName, data) +} + type durableReplyEnv struct { workspaceID, itemID, actorID, customerID, channelID int } @@ -287,6 +291,59 @@ func TestDurableEmailReplySuppressesIneligibleCommittedComments(t *testing.T) { } } +func TestDurableEmailReplySuppressesImportedComments(t *testing.T) { + db := createTestDB(t) + defer func() { _ = db.Close() }() + env := setupDurableReplyEnv(t, db) + sender := &durableReplySender{rendered: make(chan error, 1)} + replies := NewEmailReplyService(db, sender) + engine := replyEngine(db, 5*time.Millisecond) + if err := PrepareDurableEmailReplyEngine(context.Background(), engine, replies); err != nil { + t.Fatalf("prepare reply consumer: %v", err) + } + comments := NewCommentService(db) + comments.SetEmailReplyService(replies) + if _, err := comments.CreateImported(CreateCommentParams{ + ItemID: env.itemID, AuthorID: env.actorID, ActorUserID: env.actorID, + Content: "A historical imported reply", + EventMetadata: itemevents.Import("jira-job-1"), + }); err != nil { + t.Fatalf("create imported comment: %v", err) + } + created, err := engine.Store().Reconcile(context.Background(), 20) + if err != nil { + t.Fatalf("reconcile imported comment: %v", err) + } + if created != 1 { + t.Fatalf("durable deliveries = %d, want 1 committed fact", created) + } + delivery, err := engine.Store().Claim(context.Background(), DurableEmailReplyConsumerKey, "test", time.Now(), time.Minute) + if err != nil || delivery == nil { + t.Fatalf("claim imported delivery: delivery=%v err=%v", delivery, err) + } + if delivery.Event.SourceKind != "import" { + t.Fatalf("delivery source kind = %q, want import", delivery.Event.SourceKind) + } + if err := (&DurableEmailReplyConsumer{replies: replies}).Handle(context.Background(), delivery.Event); err != nil { + t.Fatalf("handle imported delivery: %v", err) + } + if err := engine.Store().Complete(context.Background(), *delivery, time.Now()); err != nil { + t.Fatalf("complete imported delivery: %v", err) + } + var outboxCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM email_reply_outbox`).Scan(&outboxCount); err != nil { + t.Fatalf("count imported outbox: %v", err) + } + if outboxCount != 0 { + t.Fatalf("imported comment created %d reply intents", outboxCount) + } + sender.mu.Lock() + defer sender.mu.Unlock() + if sender.renderCalls != 0 || sender.sendCalls != 0 { + t.Fatalf("render/send calls = %d/%d, want 0/0", sender.renderCalls, sender.sendCalls) + } +} + func TestRolledBackCommentCreatesNoDurableEmailReplyDelivery(t *testing.T) { db := createTestDB(t) defer func() { _ = db.Close() }() diff --git a/internal/services/durable_notification_consumer_test.go b/internal/services/durable_notification_consumer_test.go index abb3975..67f9e00 100644 --- a/internal/services/durable_notification_consumer_test.go +++ b/internal/services/durable_notification_consumer_test.go @@ -175,7 +175,7 @@ func TestDurableNotificationAdmissionDoesNotDropAtLegacyQueueCapacity(t *testing } } var count int - if err := db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE event_type = ?`, DurableNotificationCompatibilityEvent).Scan(&count); err != nil { + if err := db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE event_type = ?`, DurableNotificationRequestedEvent).Scan(&count); err != nil { t.Fatalf("count durable notification events: %v", err) } if count != 20 { @@ -183,6 +183,147 @@ func TestDurableNotificationAdmissionDoesNotDropAtLegacyQueueCapacity(t *testing } } +func TestDurableNotificationSkipsImportedFacts(t *testing.T) { + db := createTestDB(t) + defer func() { _ = db.Close() }() + creatorID := insertUser(t, db, "import-notify-creator@example.com", "import-notify-creator") + workspaceID := insertWorkspace(t, db, "Imported notifications", "IMN") + attachDurableNotificationRule(t, db, workspaceID, creatorID, models.EventItemCreated) + manager := &retryingNotificationManager{delivered: make(chan struct{}, 1)} + service := newDurableNotificationService(t, db, manager) + engine := durableNotificationEngine(db) + if err := PrepareDurableNotificationEngine(context.Background(), engine, service); err != nil { + t.Fatalf("prepare engine: %v", err) + } + + item := &models.Item{ID: 7101, WorkspaceID: workspaceID, WorkspaceItemNumber: 1, Title: "Imported item", CreatorID: &creatorID} + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + t.Fatalf("begin import transaction: %v", err) + } + if _, err := itemevents.NewRecorder(db).Created(context.Background(), tx, item, nil, itemevents.Import("jira-job-1")); err != nil { + t.Fatalf("record imported item event: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit imported item event: %v", err) + } + created, err := engine.Store().Reconcile(context.Background(), 20) + if err != nil { + t.Fatalf("reconcile imported event: %v", err) + } + if created != 1 { + t.Fatalf("imported event created %d deliveries, want 1", created) + } + delivery, err := engine.Store().Claim(context.Background(), DurableItemNotificationConsumerKey, "test", time.Now(), time.Minute) + if err != nil || delivery == nil { + t.Fatalf("claim imported delivery: delivery=%v err=%v", delivery, err) + } + if !itemevents.IsImportOrigin(delivery.Event.ActorKind, delivery.Event.SourceKind) { + t.Fatalf("delivery origin = (%q, %q), want import", delivery.Event.ActorKind, delivery.Event.SourceKind) + } + consumer := &DurableNotificationConsumer{db: db, notifications: service} + if err := consumer.Handle(context.Background(), delivery.Event); err != nil { + t.Fatalf("handle imported delivery: %v", err) + } + manager.mu.Lock() + defer manager.mu.Unlock() + if len(manager.notifications) != 0 { + t.Fatalf("imported fact produced %d notifications, want 0", len(manager.notifications)) + } +} + +// TestDurableNotificationSkipsSuppressedFacts asserts the explicit +// suppress_side_effects marker (external reconciliation) keeps a canonical fact +// out of the notification pipeline even though it is not import origin. +func TestDurableNotificationSkipsSuppressedFacts(t *testing.T) { + db := createTestDB(t) + defer func() { _ = db.Close() }() + creatorID := insertUser(t, db, "suppressed-notify-creator@example.com", "suppressed-notify-creator") + workspaceID := insertWorkspace(t, db, "Suppressed notifications", "SPN") + attachDurableNotificationRule(t, db, workspaceID, creatorID, models.EventItemCreated) + manager := &retryingNotificationManager{delivered: make(chan struct{}, 1)} + service := newDurableNotificationService(t, db, manager) + engine := durableNotificationEngine(db) + if err := PrepareDurableNotificationEngine(context.Background(), engine, service); err != nil { + t.Fatalf("prepare engine: %v", err) + } + + item := &models.Item{ID: 7201, WorkspaceID: workspaceID, WorkspaceItemNumber: 1, Title: "Reconciled item", CreatorID: &creatorID} + metadata := itemevents.System("github_issue_sync") + metadata.SuppressSideEffects = true + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + t.Fatalf("begin reconciliation transaction: %v", err) + } + if _, err := itemevents.NewRecorder(db).Created(context.Background(), tx, item, nil, metadata); err != nil { + t.Fatalf("record suppressed item event: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit suppressed item event: %v", err) + } + if _, err := engine.Store().Reconcile(context.Background(), 20); err != nil { + t.Fatalf("reconcile suppressed event: %v", err) + } + delivery, err := engine.Store().Claim(context.Background(), DurableItemNotificationConsumerKey, "test", time.Now(), time.Minute) + if err != nil || delivery == nil { + t.Fatalf("claim suppressed delivery: delivery=%v err=%v", delivery, err) + } + if !itemevents.SuppressesSideEffects(delivery.Event.Type, delivery.Event.Payload) { + t.Fatalf("delivery payload = %s, want suppress_side_effects", delivery.Event.Payload) + } + consumer := &DurableNotificationConsumer{db: db, notifications: service} + if err := consumer.Handle(context.Background(), delivery.Event); err != nil { + t.Fatalf("handle suppressed delivery: %v", err) + } + manager.mu.Lock() + defer manager.mu.Unlock() + if len(manager.notifications) != 0 { + t.Fatalf("suppressed fact produced %d notifications, want 0", len(manager.notifications)) + } +} + +// TestDurableNotificationSkipsImportedSideEffectEvents asserts provenance +// carried on a side-effect event (approvals, SLA, reminders) is honored the +// same way as on a canonical domain fact. +func TestDurableNotificationSkipsImportedSideEffectEvents(t *testing.T) { + db := createTestDB(t) + defer func() { _ = db.Close() }() + creatorID := insertUser(t, db, "import-sideeffect-creator@example.com", "import-sideeffect-creator") + workspaceID := insertWorkspace(t, db, "Imported SLA notifications", "ISN") + attachDurableNotificationRule(t, db, workspaceID, creatorID, models.EventSLABreached) + manager := &retryingNotificationManager{delivered: make(chan struct{}, 1)} + service := newDurableNotificationService(t, db, manager) + engine := durableNotificationEngine(db) + if err := PrepareDurableNotificationEngine(context.Background(), engine, service); err != nil { + t.Fatalf("prepare engine: %v", err) + } + + service.EmitEvent(&NotificationEvent{ + EventType: models.EventSLABreached, WorkspaceID: workspaceID, ItemID: 7301, + CreatorID: &creatorID, Title: "SLA breached", + ActorKind: "import", SourceKind: "import", + }) + if _, err := engine.Store().Reconcile(context.Background(), 20); err != nil { + t.Fatalf("reconcile side-effect event: %v", err) + } + delivery, err := engine.Store().Claim(context.Background(), DurableNotificationRequestedConsumerKey, "test", time.Now(), time.Minute) + if err != nil || delivery == nil { + t.Fatalf("claim side-effect delivery: delivery=%v err=%v", delivery, err) + } + if !itemevents.IsImportOrigin(delivery.Event.ActorKind, delivery.Event.SourceKind) { + t.Fatalf("side-effect origin = (%q, %q), want import", delivery.Event.ActorKind, delivery.Event.SourceKind) + } + consumer := &DurableNotificationConsumer{db: db, notifications: service} + if err := consumer.Handle(context.Background(), delivery.Event); err != nil { + t.Fatalf("handle side-effect delivery: %v", err) + } + manager.mu.Lock() + defer manager.mu.Unlock() + if len(manager.notifications) != 0 { + t.Fatalf("imported side effect produced %d notifications, want 0", len(manager.notifications)) + } +} + func TestCanonicalNotificationIntentCommitsAndRollsBackWithMutation(t *testing.T) { db := createTestDB(t) defer func() { _ = db.Close() }() diff --git a/internal/services/email_reply_outbox_admin_test.go b/internal/services/email_reply_outbox_admin_test.go index 0bcb624..39d6c78 100644 --- a/internal/services/email_reply_outbox_admin_test.go +++ b/internal/services/email_reply_outbox_admin_test.go @@ -268,3 +268,148 @@ func TestChannelRepository_DiscardEmailReply(t *testing.T) { t.Fatalf("delivered discard = (%v, %v), want (NotDiscardable, nil)", deliveredRow, err) } } + +// TestChannelRepository_DiscardPendingEmailReplies pins the bulk cancel: every +// pending row on the channel (replies and intake receipts alike) is discarded +// in one statement, while delivered, in-flight, and other-channel rows keep +// their state. +func TestChannelRepository_DiscardPendingEmailReplies(t *testing.T) { + db := createCommentTestDB(t) + env := setupEmailReplyTestEnv(t, db) + for _, commentID := range []int{9101, 9102, 9103, 9104} { + insertTestComment(t, db, commentID, env.ItemID, env.UserID) + insertPendingReply(t, db, env, commentID, 0, 0) + } + receiptID := insertPendingReceipt(t, db, env) + + // A delivered row and a leased (in-flight) row must survive the bulk cancel. + if _, err := db.Exec(`UPDATE email_reply_outbox SET delivered_at = CURRENT_TIMESTAMP WHERE comment_id = 9103`); err != nil { + t.Fatalf("mark delivered: %v", err) + } + if _, err := db.Exec(`UPDATE email_reply_outbox SET lease_owner = 'worker-a', next_attempt_at = ? WHERE comment_id = 9104`, time.Now().Add(time.Hour).UTC()); err != nil { + t.Fatalf("lease in-flight reply: %v", err) + } + + // A pending row owned by another channel is not the target's to cancel. + var otherChannelID int + if err := db.QueryRow(` + INSERT INTO channels (name, type, direction, status) + VALUES ('Other outbox', 'email', 'inbound', 'enabled') + RETURNING id + `).Scan(&otherChannelID); err != nil { + t.Fatalf("seed other channel: %v", err) + } + if _, err := db.Exec(` + INSERT INTO email_reply_outbox ( + comment_id, channel_id, item_id, to_email, subject, html_body, text_body, message_id, from_email + ) VALUES (9101, ?, ?, 'other@example.com', 'Re: Other', 'body', 'body', '', 'noreply@test.com') + `, otherChannelID, env.ItemID); err != nil { + t.Fatalf("seed other-channel reply: %v", err) + } + + repo := repository.NewChannelRepository(db) + discarded, err := repo.DiscardPendingEmailReplies(t.Context(), int(env.ChannelID)) + if err != nil { + t.Fatalf("DiscardPendingEmailReplies: %v", err) + } + if discarded != 3 { + t.Fatalf("discarded = %d, want 3", discarded) + } + + for _, commentID := range []int{9101, 9102} { + if _, _, _, discardedAt, _ := outboxRowState(t, db, commentID); discardedAt == nil { + t.Fatalf("comment %d not discarded", commentID) + } + } + var receiptDiscarded *time.Time + if err := db.QueryRow(`SELECT discarded_at FROM email_reply_outbox WHERE id = ?`, receiptID).Scan(&receiptDiscarded); err != nil || receiptDiscarded == nil { + t.Fatalf("receipt discarded_at = %v (err %v), want set", receiptDiscarded, err) + } + + _, _, deliveredAt, deliveredDiscarded, _ := outboxRowState(t, db, 9103) + if deliveredAt == nil || deliveredDiscarded != nil { + t.Fatalf("delivered row = delivered %v discarded %v, want delivered and not discarded", deliveredAt, deliveredDiscarded) + } + var leaseOwner *string + var inFlightDiscarded *time.Time + if err := db.QueryRow(`SELECT lease_owner, discarded_at FROM email_reply_outbox WHERE comment_id = 9104`).Scan(&leaseOwner, &inFlightDiscarded); err != nil { + t.Fatalf("load in-flight row: %v", err) + } + if leaseOwner == nil || *leaseOwner != "worker-a" || inFlightDiscarded != nil { + t.Fatalf("in-flight row = lease %v discarded %v, want worker-a lease and not discarded", leaseOwner, inFlightDiscarded) + } + + var otherDiscarded *time.Time + if err := db.QueryRow(`SELECT discarded_at FROM email_reply_outbox WHERE channel_id = ?`, otherChannelID).Scan(&otherDiscarded); err != nil || otherDiscarded != nil { + t.Fatalf("other-channel discarded_at = %v (err %v), want NULL", otherDiscarded, err) + } + + if again, err := repo.DiscardPendingEmailReplies(t.Context(), int(env.ChannelID)); err != nil || again != 0 { + t.Fatalf("repeat discard = (%d, %v), want (0, nil)", again, err) + } +} + +// insertPendingReceipt seeds an intake_confirmation outbox row. Receipts carry +// no comment_id, so the outbox row id is their only handle. The row is inserted +// directly because only the inbound-mail pipeline enqueues receipts. +func insertPendingReceipt(t *testing.T, db database.Database, env emailReplyTestEnv) int { + t.Helper() + var id int + if err := db.QueryRow(` + INSERT INTO email_reply_outbox ( + kind, comment_id, channel_id, item_id, to_email, to_name, subject, + html_body, text_body, message_id, in_reply_to, references_json, + from_email, from_name, attempt_count, next_attempt_at, created_at, updated_at + ) VALUES ('intake_confirmation', NULL, ?, ?, 'customer@example.com', 'Customer One', 'We received your request', + '

body

', 'body', ?, '', '[]', + 'noreply@test.com', 'Test App', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + `, env.ChannelID, env.ItemID, fmt.Sprintf("", env.ItemID)).Scan(&id); err != nil { + t.Fatalf("seed intake receipt: %v", err) + } + return id +} + +// TestChannelRepository_DiscardEmailReplyByID pins the row-scoped discard: an +// intake receipt (no comment_id) cancels once, never across channels, and the +// delivered and in-flight states keep their exact refusals. +func TestChannelRepository_DiscardEmailReplyByID(t *testing.T) { + db := createCommentTestDB(t) + env := setupEmailReplyTestEnv(t, db) + repo := repository.NewChannelRepository(db) + ctx := t.Context() + + receiptID := insertPendingReceipt(t, db, env) + + if outcome, err := repo.DiscardEmailReplyByID(ctx, int(env.ChannelID), receiptID); err != nil || outcome != repository.EmailReplyDiscarded { + t.Fatalf("pending receipt discard = (%v, %v), want (Discarded, nil)", outcome, err) + } + var discardedAt *time.Time + if err := db.QueryRow(`SELECT discarded_at FROM email_reply_outbox WHERE id = ?`, receiptID).Scan(&discardedAt); err != nil || discardedAt == nil { + t.Fatalf("receipt discarded_at = %v (err %v), want set", discardedAt, err) + } + + if again, err := repo.DiscardEmailReplyByID(ctx, int(env.ChannelID), receiptID); err != nil || again != repository.EmailReplyNotDiscardable { + t.Fatalf("repeat receipt discard = (%v, %v), want (NotDiscardable, nil)", again, err) + } + + // A channel that does not own the row must not be able to cancel it. + if wrong, err := repo.DiscardEmailReplyByID(ctx, int(env.ChannelID)+999, receiptID); err != nil || wrong != repository.EmailReplyNotDiscardable { + t.Fatalf("cross-channel receipt discard = (%v, %v), want (NotDiscardable, nil)", wrong, err) + } + + if _, err := db.Exec(`UPDATE email_reply_outbox SET discarded_at = NULL, delivered_at = CURRENT_TIMESTAMP WHERE id = ?`, receiptID); err != nil { + t.Fatalf("mark receipt delivered: %v", err) + } + if delivered, err := repo.DiscardEmailReplyByID(ctx, int(env.ChannelID), receiptID); err != nil || delivered != repository.EmailReplyNotDiscardable { + t.Fatalf("delivered receipt discard = (%v, %v), want (NotDiscardable, nil)", delivered, err) + } + + // A live lease reports Sending instead of a false cancellation. + if _, err := db.Exec(`UPDATE email_reply_outbox SET delivered_at = NULL, lease_owner = 'worker-a', next_attempt_at = ? WHERE id = ?`, time.Now().Add(time.Hour).UTC(), receiptID); err != nil { + t.Fatalf("lease receipt: %v", err) + } + if sending, err := repo.DiscardEmailReplyByID(ctx, int(env.ChannelID), receiptID); err != nil || sending != repository.EmailReplySending { + t.Fatalf("in-flight receipt discard = (%v, %v), want (Sending, nil)", sending, err) + } +} diff --git a/internal/services/email_reply_service_core_regression_test.go b/internal/services/email_reply_service_core_regression_test.go index 65e75fe..9a7e26f 100644 --- a/internal/services/email_reply_service_core_regression_test.go +++ b/internal/services/email_reply_service_core_regression_test.go @@ -41,6 +41,10 @@ func (*blockingThreadedSender) RenderEmail(string, interface{}) (string, string, return "subject", "html", "text", nil } +func (*blockingThreadedSender) RenderEmailLocale(_, _ string, _ interface{}) (string, string, string, error) { + return "subject", "html", "text", nil +} + func (s *retryingThreadedSender) IsSMTPConfigured() bool { return true } func (s *retryingThreadedSender) SendThreadedEmail(p smtp.ThreadedEmailParams) error { @@ -58,6 +62,10 @@ func (*retryingThreadedSender) RenderEmail(string, interface{}) (string, string, return "subject", "html", "text", nil } +func (*retryingThreadedSender) RenderEmailLocale(_, _ string, _ interface{}) (string, string, string, error) { + return "subject", "html", "text", nil +} + func TestEmailReplyOutboxRetriesTransientFailure(t *testing.T) { db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "reply-outbox.db")) if err != nil { diff --git a/internal/services/email_reply_service_test.go b/internal/services/email_reply_service_test.go index b736a93..53942a5 100644 --- a/internal/services/email_reply_service_test.go +++ b/internal/services/email_reply_service_test.go @@ -40,6 +40,10 @@ func (m *mockSMTPSender) RenderEmail(_ string, data interface{}) (string, string return "", body, body, nil } +func (m *mockSMTPSender) RenderEmailLocale(_, templateName string, data interface{}) (string, string, string, error) { + return m.RenderEmail(templateName, data) +} + // emailReplyTestEnv holds common test data for email reply service tests. type emailReplyTestEnv struct { UserID int @@ -192,6 +196,55 @@ func TestEmailReplyService_SkipsZeroAuthor(t *testing.T) { } } +func TestEmailReplyService_SkipsImportedComment(t *testing.T) { + db := createCommentTestDB(t) + tenv := setupEmailReplyTestEnv(t, db) + mock := &mockSMTPSender{configured: true} + svc := services.NewEmailReplyService(db, mock) + + insertTrackingRecord(t, db, tenv.ChannelID, "", "Imported thread", tenv.ItemID) + insertTestComment(t, db, 200, tenv.ItemID, tenv.UserID) + + err := svc.HandleCommentCreated(services.HandleCommentParams{ + CommentID: 200, + ItemID: tenv.ItemID, + AuthorID: tenv.UserID, + Content: "historical imported reply", + SourceKind: "import", + }) + if err != nil { + t.Fatalf("Expected nil error, got: %v", err) + } + if len(mock.sent) != 0 { + t.Fatalf("Expected no email sent for imported comment, got %d", len(mock.sent)) + } +} + +func TestEmailReplyService_SkipsImportActorKindComment(t *testing.T) { + db := createCommentTestDB(t) + tenv := setupEmailReplyTestEnv(t, db) + mock := &mockSMTPSender{configured: true} + svc := services.NewEmailReplyService(db, mock) + + insertTrackingRecord(t, db, tenv.ChannelID, "", "Imported thread", tenv.ItemID) + insertTestComment(t, db, 201, tenv.ItemID, tenv.UserID) + + // ActorKind alone must be enough: some import paths stamp only that field. + err := svc.HandleCommentCreated(services.HandleCommentParams{ + CommentID: 201, + ItemID: tenv.ItemID, + AuthorID: tenv.UserID, + Content: "historical imported reply", + ActorKind: "import", + }) + if err != nil { + t.Fatalf("Expected nil error, got: %v", err) + } + if len(mock.sent) != 0 { + t.Fatalf("Expected no email sent for import-actor comment, got %d", len(mock.sent)) + } +} + func TestEmailReplyService_SkipsWhenSMTPNotConfigured(t *testing.T) { db := createCommentTestDB(t) mock := &mockSMTPSender{configured: false} diff --git a/internal/services/intake_confirmation_test.go b/internal/services/intake_confirmation_test.go index a2b0fee..18013b9 100644 --- a/internal/services/intake_confirmation_test.go +++ b/internal/services/intake_confirmation_test.go @@ -13,6 +13,7 @@ import ( "windshift/internal/database" "windshift/internal/emailutil" + "windshift/internal/i18n" "windshift/internal/smtp" "windshift/internal/testutils" ) @@ -40,16 +41,20 @@ func (s *renderingThreadedSender) SendThreadedEmailContext(_ context.Context, pa return nil } func (s *renderingThreadedSender) RenderEmail(name string, data any) (string, string, string, error) { + return s.RenderEmailLocale(i18n.Default, name, data) +} + +func (s *renderingThreadedSender) RenderEmailLocale(locale, name string, data any) (string, string, string, error) { for _, tmpl := range emailutil.DefaultTemplates() { if tmpl.Name != name { continue } - subject, _, err := emailutil.RenderTemplates(tmpl.Subject, tmpl.Subject, data) + _, subject, err := emailutil.RenderTemplatesLocale(locale, tmpl.Subject, tmpl.Subject, data) if err != nil { return "", "", "", err } - enriched := emailutil.EnrichWithSubject(data, subject) - htmlBody, textBody, err := emailutil.RenderTemplates(tmpl.HTMLBody, tmpl.TextBody, enriched) + enriched := emailutil.EnrichWithSubjectLocale(data, subject, locale) + htmlBody, textBody, err := emailutil.RenderTemplatesLocale(locale, tmpl.HTMLBody, tmpl.TextBody, enriched) return subject, htmlBody, textBody, err } return "", "", "", fmt.Errorf("unknown email template %q", name) diff --git a/internal/services/item_assignee_trigger_test.go b/internal/services/item_assignee_trigger_test.go index ce62220..ada6574 100644 --- a/internal/services/item_assignee_trigger_test.go +++ b/internal/services/item_assignee_trigger_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "windshift/internal/itemevents" "windshift/internal/testutils" ) @@ -123,6 +124,27 @@ func TestCreateItemFiresAssigneeTrigger(t *testing.T) { t.Fatalf("expected no trigger calls with SkipAssigneeTrigger, got %d", len(stub.calls)) } }) + + t.Run("ImportOriginSuppressesTrigger", func(t *testing.T) { + stub := installStubTrigger(t) + + // A bulk import must not start an agent run even when the caller forgets + // SkipAssigneeTrigger: the import marker on the creation fact is enough. + if _, err := CreateItem(tdb.GetDatabase(), ItemCreationParams{ + WorkspaceID: testData.WorkspaceID, + Title: "Imported with assignee", + ItemTypeID: &itemTypeID, + AssigneeID: &testData.UserID, + CreatorID: &testData.UserID, + EventMetadata: itemevents.Import("ticket-csv-job"), + }); err != nil { + t.Fatalf("CreateItem failed: %v", err) + } + + if len(stub.calls) != 0 { + t.Fatalf("expected no trigger calls for import-origin create, got %d", len(stub.calls)) + } + }) } // TestUpdateItemFiresAssigneeTrigger asserts the trigger now lives inside diff --git a/internal/services/item_attachment_visibility_test.go b/internal/services/item_attachment_visibility_test.go new file mode 100644 index 0000000..8ebe8d9 --- /dev/null +++ b/internal/services/item_attachment_visibility_test.go @@ -0,0 +1,88 @@ +//go:build test + +package services_test + +import ( + "context" + "testing" + + "windshift/internal/blobstore" + "windshift/internal/services" + "windshift/internal/testutils" +) + +// TestSetItemAttachmentInternalHidesFromPortal covers the service contract +// behind the v2 visibility endpoint: toggling is_internal removes the file +// from the portal list while workspace members keep seeing it flagged. +func TestSetItemAttachmentInternalHidesFromPortal(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + attachmentPath := t.TempDir() + itemID, customerID := seedPortalAttachmentItem(t, tdb, attachmentPath) + + svc := services.NewItemAttachmentService(tdb.GetDatabase(), blobstore.NewLocal(attachmentPath), nil) + uploaded, err := svc.UploadPublicFormAttachment(context.Background(), services.ItemAttachmentUploadInput{ + ItemID: itemID, + UploaderPortalCustomerID: &customerID, + OriginalFilename: "evidence.txt", + FileData: []byte("customer evidence\n"), + FileSize: int64(len("customer evidence\n")), + }) + if err != nil { + t.Fatalf("upload: %v", err) + } + + // A freshly uploaded customer file is portal-visible. + portal, total, err := svc.ListPortalRequestAttachments(itemID, 50, 0) + if err != nil { + t.Fatalf("list portal attachments: %v", err) + } + if total != 1 || len(portal) != 1 { + t.Fatalf("portal attachments = %d/%d, want 1/1", len(portal), total) + } + + actor := 1 + updated, err := svc.SetItemAttachmentInternal(uploaded.Attachment.ID, actor, true) + if err != nil { + t.Fatalf("hide attachment: %v", err) + } + if !updated.IsInternal { + t.Fatalf("returned attachment is_internal = false, want true") + } + + portal, total, err = svc.ListPortalRequestAttachments(itemID, 50, 0) + if err != nil { + t.Fatalf("list portal attachments after hide: %v", err) + } + if total != 0 || len(portal) != 0 { + t.Fatalf("portal attachments after hide = %d/%d, want 0/0", len(portal), total) + } + + member, total, err := svc.ListItemAttachments(actor, itemID, 50, 0) + if err != nil { + t.Fatalf("list member attachments: %v", err) + } + if total != 1 || len(member) != 1 || !member[0].IsInternal { + t.Fatalf("member attachments = %+v (total %d), want 1 internal", member, total) + } + + // Revealing the file restores portal visibility. + updated, err = svc.SetItemAttachmentInternal(uploaded.Attachment.ID, actor, false) + if err != nil { + t.Fatalf("reveal attachment: %v", err) + } + if updated.IsInternal { + t.Fatalf("returned attachment is_internal = true, want false") + } + portal, total, err = svc.ListPortalRequestAttachments(itemID, 50, 0) + if err != nil { + t.Fatalf("list portal attachments after reveal: %v", err) + } + if total != 1 || len(portal) != 1 { + t.Fatalf("portal attachments after reveal = %d/%d, want 1/1", len(portal), total) + } + + if _, err := svc.SetItemAttachmentInternal(999999, actor, true); err == nil { + t.Fatal("SetItemAttachmentInternal(missing) error = nil, want not found") + } +} diff --git a/internal/services/item_cache_effective_project_test.go b/internal/services/item_cache_effective_project_test.go new file mode 100644 index 0000000..0c1ace9 --- /dev/null +++ b/internal/services/item_cache_effective_project_test.go @@ -0,0 +1,183 @@ +//go:build test + +package services + +import ( + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/testutils" +) + +func newEffectiveProjectCacheService(t *testing.T, db database.Database) *ItemCacheService { + t.Helper() + service := NewItemCacheService(db, ItemCacheConfig{ + HierarchyTTL: time.Hour, + MaxCacheSize: 12, + EnablePreWarm: false, + }) + t.Cleanup(func() { _ = service.Close() }) + return service +} + +func insertTimeProject(t *testing.T, db database.Database, name string) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO time_projects (name, description, status, created_at, updated_at) + VALUES (?, 'test', 'Active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, name) +} + +func createProjectItem(t *testing.T, db database.Database, env hierarchyTestEnv, title string, projectID *int, inherit bool, parentID *int) int { + t.Helper() + id, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: env.WorkspaceID, + Title: title, + StatusID: &env.StatusID, + ProjectID: projectID, + InheritProject: inherit, + ParentID: parentID, + }) + if err != nil { + t.Fatalf("create item %q: %v", title, err) + } + return int(id) +} + +// createItemOfType creates an item with a named item type. Inheritance only +// walks up when the item's type has a non-zero hierarchy_level, so an inheriting +// child needs a deeper type than its parent. +func createItemOfType(t *testing.T, db database.Database, env hierarchyTestEnv, title, typeName string, inherit bool, parentID *int) int { + t.Helper() + var typeID int + if err := db.QueryRow("SELECT id FROM item_types WHERE name = ?", typeName).Scan(&typeID); err != nil { + t.Fatalf("find item type %s: %v", typeName, err) + } + id, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: env.WorkspaceID, + Title: title, + StatusID: &env.StatusID, + ItemTypeID: &typeID, + InheritProject: inherit, + ParentID: parentID, + }) + if err != nil { + t.Fatalf("create item %q: %v", title, err) + } + return int(id) +} + +func TestItemCacheServesDirectProjectUntilInvalidated(t *testing.T) { + db := createHierarchyTestDB(t) + env := setupHierarchyTestEnv(t, db) + service := newEffectiveProjectCacheService(t, db) + + projectA := insertTimeProject(t, db, "Cache A") + projectB := insertTimeProject(t, db, "Cache B") + itemID := createProjectItem(t, db, env, "Direct project item", &projectA, false, nil) + + got, mode, err := service.GetEffectiveProjectForItem(itemID) + if err != nil || got == nil || *got != projectA || mode != "direct" { + t.Fatalf("cold resolve = %v, %q, %v; want %d, direct", got, mode, err, projectA) + } + + // Mutate the row behind the cache: a warm read must serve the cached value. + if _, err := db.Exec(`UPDATE items SET project_id = ? WHERE id = ?`, projectB, itemID); err != nil { + t.Fatalf("update project: %v", err) + } + got, mode, err = service.GetEffectiveProjectForItem(itemID) + if err != nil || got == nil || *got != projectA || mode != "direct" { + t.Fatalf("warm resolve = %v, %q, %v; want cached %d", got, mode, err, projectA) + } + + if err := service.InvalidateItemHierarchy(itemID, nil); err != nil { + t.Fatalf("invalidate: %v", err) + } + got, mode, err = service.GetEffectiveProjectForItem(itemID) + if err != nil || got == nil || *got != projectB { + t.Fatalf("post-invalidation resolve = %v, %q, %v; want %d", got, mode, err, projectB) + } +} + +func TestItemCacheServesInheritedProjectUntilDescendantInvalidated(t *testing.T) { + db := createHierarchyTestDB(t) + env := setupHierarchyTestEnv(t, db) + service := newEffectiveProjectCacheService(t, db) + + projectA := insertTimeProject(t, db, "Inherit A") + projectB := insertTimeProject(t, db, "Inherit B") + + // Give the Epic parent a direct project, then create a Story child that + // inherits it. The Story type's non-zero hierarchy_level is what lets the + // resolver walk up. + if _, err := db.Exec(`UPDATE items SET project_id = ? WHERE id = ?`, projectA, env.ChildItem1); err != nil { + t.Fatalf("set parent project: %v", err) + } + childID := createItemOfType(t, db, env, "Inheriting story", "Story", true, &env.ChildItem1) + + got, mode, err := service.GetEffectiveProjectForItem(childID) + if err != nil || got == nil || *got != projectA || mode != "inherit" { + t.Fatalf("cold child resolve = %v, %q, %v; want %d, inherit", got, mode, err, projectA) + } + + // Change the parent's project behind the cache; a warm child read keeps the + // cached inherited value. + if _, err := db.Exec(`UPDATE items SET project_id = ? WHERE id = ?`, projectB, env.ChildItem1); err != nil { + t.Fatalf("update parent project: %v", err) + } + got, _, _ = service.GetEffectiveProjectForItem(childID) + if got == nil || *got != projectA { + t.Fatalf("warm child resolve = %v, want cached %d", got, projectA) + } + + // The production mutation path invalidates descendants after a parent + // change; the next read resolves the new inherited project. + if err := service.InvalidateItemHierarchy(childID, nil); err != nil { + t.Fatalf("invalidate child: %v", err) + } + got, mode, err = service.GetEffectiveProjectForItem(childID) + if err != nil || got == nil || *got != projectB || mode != "inherit" { + t.Fatalf("post-invalidation child resolve = %v, %q, %v; want %d, inherit", got, mode, err, projectB) + } +} + +func TestItemCRUDServiceUsesInjectedEffectiveProjectCache(t *testing.T) { + db := createHierarchyTestDB(t) + env := setupHierarchyTestEnv(t, db) + service := newEffectiveProjectCacheService(t, db) + + projectA := insertTimeProject(t, db, "CRUD A") + projectB := insertTimeProject(t, db, "CRUD B") + itemID := createProjectItem(t, db, env, "CRUD cache item", &projectA, false, nil) + + cached := NewItemCRUDService(db).WithCache(service) + item, err := cached.GetWithEffectiveProject(itemID) + if err != nil { + t.Fatalf("cached GetWithEffectiveProject: %v", err) + } + if item.EffectiveProjectID == nil || *item.EffectiveProjectID != projectA || item.ProjectInheritanceMode != "direct" { + t.Fatalf("cached read = %v/%q, want %d/direct", item.EffectiveProjectID, item.ProjectInheritanceMode, projectA) + } + + if _, err := db.Exec(`UPDATE items SET project_id = ? WHERE id = ?`, projectB, itemID); err != nil { + t.Fatalf("update project: %v", err) + } + item, err = cached.GetWithEffectiveProject(itemID) + if err != nil { + t.Fatalf("warm cached GetWithEffectiveProject: %v", err) + } + if item.EffectiveProjectID == nil || *item.EffectiveProjectID != projectA { + t.Fatalf("warm cached read = %v, want cached %d", item.EffectiveProjectID, projectA) + } + + // Without a cache the same service resolves fresh. + uncached := NewItemCRUDService(db) + item, err = uncached.GetWithEffectiveProject(itemID) + if err != nil { + t.Fatalf("uncached GetWithEffectiveProject: %v", err) + } + if item.EffectiveProjectID == nil || *item.EffectiveProjectID != projectB { + t.Fatalf("uncached read = %v, want fresh %d", item.EffectiveProjectID, projectB) + } +} diff --git a/internal/services/item_cache_test.go b/internal/services/item_cache_test.go index 7f7120c..22b1e7f 100644 --- a/internal/services/item_cache_test.go +++ b/internal/services/item_cache_test.go @@ -9,33 +9,26 @@ import ( "windshift/internal/cacheutil" ) -func TestItemCacheUsesEntireAllocationWithoutWorkspaceProjectCache(t *testing.T) { - service, err := NewItemCacheService(nil, ItemCacheConfig{ +func TestItemCacheRegistersTypedEffectiveProjectCache(t *testing.T) { + service := NewItemCacheService(nil, ItemCacheConfig{ HierarchyTTL: time.Hour, MaxCacheSize: 12, WarmupBatchSize: 1, EnablePreWarm: false, }) - if err != nil { - t.Fatalf("NewItemCacheService() error = %v", err) - } - t.Cleanup(func() { _ = service.hierarchyCache.Close() }) + t.Cleanup(func() { _ = service.Close() }) - wantMaximum := int64(12 * 1024 * 1024) - foundHierarchy := false + found := false for _, snapshot := range cacheutil.Snapshots() { switch snapshot.Name { - case "item_hierarchy": - foundHierarchy = true - if snapshot.MaximumCapacityBytes != wantMaximum { - t.Fatalf("item_hierarchy maximum = %d, want %d", snapshot.MaximumCapacityBytes, wantMaximum) - } - case "item_projects": - t.Fatalf("unused item_projects cache is still registered: %+v", snapshot) + case "item_effective_project": + found = true + case "item_hierarchy", "item_projects": + t.Fatalf("stale cache registered in diagnostics: %+v", snapshot) } } - if !foundHierarchy { - t.Fatal("item_hierarchy cache missing from diagnostics") + if !found { + t.Fatal("item_effective_project cache missing from diagnostics") } stats := service.GetStats() @@ -45,3 +38,16 @@ func TestItemCacheUsesEntireAllocationWithoutWorkspaceProjectCache(t *testing.T) } } } + +func TestItemCacheMaxEntries(t *testing.T) { + if got := itemCacheMaxEntries(ItemCacheConfig{MaxEntries: 42, MaxCacheSize: 16}); got != 42 { + t.Fatalf("explicit MaxEntries = %d, want 42", got) + } + wantDerived := 16 * 1024 * 1024 / itemEffectiveProjectEstimateBytes + if got := itemCacheMaxEntries(ItemCacheConfig{MaxCacheSize: 16}); got != wantDerived { + t.Fatalf("derived MaxEntries = %d, want %d", got, wantDerived) + } + if got := itemCacheMaxEntries(ItemCacheConfig{}); got != 0 { + t.Fatalf("unconfigured MaxEntries = %d, want 0 (unbounded)", got) + } +} diff --git a/internal/services/item_creation_service_test.go b/internal/services/item_creation_service_test.go index 690832f..ee76161 100644 --- a/internal/services/item_creation_service_test.go +++ b/internal/services/item_creation_service_test.go @@ -121,6 +121,81 @@ func TestItemCreationServiceValidatesAssignee(t *testing.T) { } } +func TestItemCreationService_ResolvesChildTypeWhenOmitted(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + + // A top-level parent type and the type one hierarchy level below it. + var parentTypeID, parentLevel, childTypeID int + if err := tdb.QueryRow(` + SELECT id, hierarchy_level FROM item_types + WHERE hierarchy_level >= 0 + ORDER BY hierarchy_level, id LIMIT 1 + `).Scan(&parentTypeID, &parentLevel); err != nil { + t.Fatalf("load parent item type: %v", err) + } + if err := tdb.QueryRow(` + SELECT id FROM item_types + WHERE hierarchy_level = ? + ORDER BY id LIMIT 1 + `, parentLevel+1).Scan(&childTypeID); err != nil { + t.Fatalf("load child item type: %v", err) + } + // The regression only bites when the workspace default is a different + // (top-level) type than the legal child type. + var defaultTypeID int + if err := tdb.QueryRow(`SELECT id FROM item_types WHERE is_default = true ORDER BY id LIMIT 1`).Scan(&defaultTypeID); err != nil { + t.Fatalf("load default item type: %v", err) + } + if defaultTypeID == childTypeID { + t.Skip("default item type already equals the legal child type") + } + + perm, err := NewPermissionService(tdb.GetDatabase(), DefaultPermissionCacheConfig()) + if err != nil { + t.Fatalf("create permission service: %v", err) + } + service := NewItemCreationService(tdb.GetDatabase(), perm) + + parent, err := service.Create(data.UserID, "testuser", ItemCreateInput{ + WorkspaceID: data.WorkspaceID, + ItemTypeID: &parentTypeID, + Title: "Parent for omitted child type", + }) + if err != nil { + t.Fatalf("create parent: %v", err) + } + + child, err := service.Create(data.UserID, "testuser", ItemCreateInput{ + WorkspaceID: data.WorkspaceID, + ParentID: &parent.Item.ID, + Title: "Child without an explicit type", + }) + if err != nil { + t.Fatalf("create child without a type: %v", err) + } + gotType := -1 + if child.Item.ItemTypeID != nil { + gotType = *child.Item.ItemTypeID + } + if gotType != childTypeID { + t.Fatalf("child item type = %d, want next-level type %d", gotType, childTypeID) + } + + var persistedTypeID, persistedLevel int + if err := tdb.QueryRow(` + SELECT i.item_type_id, it.hierarchy_level + FROM items i JOIN item_types it ON it.id = i.item_type_id + WHERE i.id = ? + `, child.Item.ID).Scan(&persistedTypeID, &persistedLevel); err != nil { + t.Fatalf("load persisted child type: %v", err) + } + if persistedTypeID != childTypeID || persistedLevel != parentLevel+1 { + t.Fatalf("persisted child type/level = %d/%d, want %d/%d", persistedTypeID, persistedLevel, childTypeID, parentLevel+1) + } +} + func (e *recordingItemCreatedEmitter) EmitItemCreated(item *models.Item, actorUserID int, actorUsername ...string) { e.items = append(e.items, item) e.actorUserID = actorUserID @@ -129,6 +204,48 @@ func (e *recordingItemCreatedEmitter) EmitItemCreated(item *models.Item, actorUs } } +// TestItemCreationServiceCreateImportedSuppressesLiveSideEffects asserts the +// canonical validation/persistence path is still used for imports, but the +// live emitter, the assignee trigger, and the canonical fact's actor all agree +// that the write is historical. +func TestItemCreationServiceCreateImportedSuppressesLiveSideEffects(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + + emitter := &recordingItemCreatedEmitter{} + perm, err := NewPermissionService(tdb.GetDatabase(), DefaultPermissionCacheConfig()) + if err != nil { + t.Fatalf("create permission service: %v", err) + } + service := NewItemCreationService(tdb.GetDatabase(), perm) + service.SetEmitter(emitter) + + created, err := service.CreateImported(data.UserID, "importer", ItemCreateInput{ + WorkspaceID: data.WorkspaceID, + Title: "Bundle seed item", + AssigneeID: &data.UserID, + }, "workspace_bundle") + if err != nil { + t.Fatalf("CreateImported failed: %v", err) + } + + if len(emitter.items) != 0 { + t.Fatalf("import emitted %d live item-created events, want 0", len(emitter.items)) + } + + var actorKind, sourceKind string + if err := tdb.QueryRow(` + SELECT actor_kind, source_kind FROM domain_events + WHERE event_type = 'item.created' AND aggregate_id = ? + `, created.Item.ID).Scan(&actorKind, &sourceKind); err != nil { + t.Fatalf("load creation fact provenance: %v", err) + } + if actorKind != "import" || sourceKind != "import" { + t.Fatalf("creation fact provenance = %q/%q, want import/import", actorKind, sourceKind) + } +} + func TestItemCreationService_PreservesSourcePersistsAndEmitsCommittedItem(t *testing.T) { tdb := testutils.CreateTestDB(t, true) t.Cleanup(func() { tdb.Close() }) diff --git a/internal/services/item_crud_backlog_scope_test.go b/internal/services/item_crud_backlog_scope_test.go new file mode 100644 index 0000000..25344c7 --- /dev/null +++ b/internal/services/item_crud_backlog_scope_test.go @@ -0,0 +1,185 @@ +//go:build test + +package services_test + +import ( + "fmt" + "testing" + + "windshift/internal/services" + "windshift/internal/testutils" + "windshift/internal/testutils/factory" +) + +// A collection backlog must honor the collection's own board configuration. +// The frontend sends only collection_id for these requests, so the scope has to +// be resolved from the collection rather than from a workspace parameter. +func TestItemCRUDService_GetBacklogItemsUsesCollectionScopedStatuses(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + f := factory.NewTestFactory(db) + + creatorID, workspaceID, err := f.CreateUserAndWorkspace() + if err != nil { + t.Fatalf("create user/workspace: %v", err) + } + + var itemTypeID, openStatusID, doneStatusID int + if err := db.QueryRow("SELECT id FROM item_types LIMIT 1").Scan(&itemTypeID); err != nil { + t.Fatalf("get item type: %v", err) + } + if err := db.QueryRow(`SELECT id FROM statuses WHERE name = 'Open'`).Scan(&openStatusID); err != nil { + t.Fatalf("get Open status: %v", err) + } + if err := db.QueryRow(`SELECT id FROM statuses WHERE name = 'Done'`).Scan(&doneStatusID); err != nil { + t.Fatalf("get Done status: %v", err) + } + + collectionResult, err := db.ExecWrite( + `INSERT INTO collections (name, ql_query, workspace_id, created_by) VALUES (?, '', ?, ?)`, + "Backlog scope", + workspaceID, + creatorID, + ) + if err != nil { + t.Fatalf("create collection: %v", err) + } + collectionID64, err := collectionResult.LastInsertId() + if err != nil { + t.Fatalf("collection LastInsertId: %v", err) + } + collectionID := int(collectionID64) + + if _, err := db.ExecWrite( + "INSERT INTO board_configurations (collection_id, backlog_status_ids) VALUES (?, ?)", + collectionID, + fmt.Sprintf("[%d, %d]", openStatusID, doneStatusID), + ); err != nil { + t.Fatalf("create collection backlog config: %v", err) + } + + openItemID, err := f.CreateItem(factory.CreateItemOpts{ + WorkspaceID: workspaceID, + Title: "Open backlog item", + StatusID: &openStatusID, + ItemTypeID: &itemTypeID, + CreatorID: &creatorID, + }) + if err != nil { + t.Fatalf("create open item: %v", err) + } + doneItemID, err := f.CreateItem(factory.CreateItemOpts{ + WorkspaceID: workspaceID, + Title: "Done backlog item", + StatusID: &doneStatusID, + ItemTypeID: &itemTypeID, + CreatorID: &creatorID, + }) + if err != nil { + t.Fatalf("create done item: %v", err) + } + + items, total, err := services.NewItemCRUDService(db).GetBacklogItems(services.BacklogParams{ + CollectionID: collectionID, + WorkspaceIDs: []int{workspaceID}, + UserID: creatorID, + Pagination: services.PaginationParams{Limit: 50}, + }) + if err != nil { + t.Fatalf("get backlog: %v", err) + } + if total != 2 || len(items) != 2 { + t.Fatalf("collection backlog returned total=%d len=%d, want 2/2", total, len(items)) + } + returned := map[int]bool{} + for _, item := range items { + returned[item.ID] = true + } + if !returned[openItemID] || !returned[doneItemID] { + t.Fatalf("collection backlog returned ids %v, want open=%d and done=%d", returned, openItemID, doneItemID) + } +} + +// A collection without its own board configuration uses the global +// non-completed statuses, matching the defaults the board configuration editor +// shows for that scope. A workspace-scoped configuration must not leak into it. +func TestItemCRUDService_GetBacklogItemsFallsBackForUnconfiguredCollection(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + f := factory.NewTestFactory(db) + + creatorID, workspaceID, err := f.CreateUserAndWorkspace() + if err != nil { + t.Fatalf("create user/workspace: %v", err) + } + + var itemTypeID, openStatusID, doneStatusID int + if err := db.QueryRow("SELECT id FROM item_types LIMIT 1").Scan(&itemTypeID); err != nil { + t.Fatalf("get item type: %v", err) + } + if err := db.QueryRow(`SELECT id FROM statuses WHERE name = 'Open'`).Scan(&openStatusID); err != nil { + t.Fatalf("get Open status: %v", err) + } + if err := db.QueryRow(`SELECT id FROM statuses WHERE name = 'Done'`).Scan(&doneStatusID); err != nil { + t.Fatalf("get Done status: %v", err) + } + + // Workspace default includes Done; the collection has no configuration. + if _, err := db.ExecWrite( + "INSERT INTO board_configurations (workspace_id, backlog_status_ids) VALUES (?, ?)", + workspaceID, + fmt.Sprintf("[%d, %d]", openStatusID, doneStatusID), + ); err != nil { + t.Fatalf("create workspace backlog config: %v", err) + } + + collectionResult, err := db.ExecWrite( + `INSERT INTO collections (name, ql_query, workspace_id, created_by) VALUES (?, '', ?, ?)`, + "Unconfigured backlog scope", + workspaceID, + creatorID, + ) + if err != nil { + t.Fatalf("create collection: %v", err) + } + collectionID64, err := collectionResult.LastInsertId() + if err != nil { + t.Fatalf("collection LastInsertId: %v", err) + } + collectionID := int(collectionID64) + + openItemID, err := f.CreateItem(factory.CreateItemOpts{ + WorkspaceID: workspaceID, + Title: "Open backlog item", + StatusID: &openStatusID, + ItemTypeID: &itemTypeID, + CreatorID: &creatorID, + }) + if err != nil { + t.Fatalf("create open item: %v", err) + } + if _, err := f.CreateItem(factory.CreateItemOpts{ + WorkspaceID: workspaceID, + Title: "Done backlog item", + StatusID: &doneStatusID, + ItemTypeID: &itemTypeID, + CreatorID: &creatorID, + }); err != nil { + t.Fatalf("create done item: %v", err) + } + + items, total, err := services.NewItemCRUDService(db).GetBacklogItems(services.BacklogParams{ + CollectionID: collectionID, + WorkspaceIDs: []int{workspaceID}, + UserID: creatorID, + Pagination: services.PaginationParams{Limit: 50}, + }) + if err != nil { + t.Fatalf("get backlog: %v", err) + } + if total != 1 || len(items) != 1 || items[0].ID != openItemID { + t.Fatalf("unconfigured collection backlog returned total=%d items=%v, want only open item %d", total, items, openItemID) + } +} diff --git a/internal/services/item_delete_key_reservation_test.go b/internal/services/item_delete_key_reservation_test.go new file mode 100644 index 0000000..e6b41b7 --- /dev/null +++ b/internal/services/item_delete_key_reservation_test.go @@ -0,0 +1,140 @@ +//go:build test + +package services + +import ( + "testing" + + "windshift/internal/testutils" +) + +func TestDeleteSingleRetiresHighestItemNumber(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + firstID := createKeyNumberItem(t, db, data.WorkspaceID, "First", nil) + secondID := createKeyNumberItem(t, db, data.WorkspaceID, "Second", nil) + if got := itemNumberByID(t, db, secondID); got != 2 { + t.Fatalf("second item number = %d, want 2", got) + } + + if err := NewItemCRUDService(db).DeleteSingle(secondID); err != nil { + t.Fatalf("delete highest item: %v", err) + } + if got := keyReservationCount(t, db, data.WorkspaceID, 2); got != 1 { + t.Fatalf("reservation rows for 2 = %d, want 1", got) + } + + nextID := createKeyNumberItem(t, db, data.WorkspaceID, "Third", nil) + if got := itemNumberByID(t, db, nextID); got != 3 { + t.Fatalf("next item number = %d, want 3 (retired key not reissued)", got) + } + if got := itemNumberByID(t, db, firstID); got != 1 { + t.Fatalf("surviving item number = %d, want unchanged 1", got) + } +} + +func TestDeleteNonMaxItemDoesNotChangeNextNumber(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + firstID := createKeyNumberItem(t, db, data.WorkspaceID, "First", nil) + createKeyNumberItem(t, db, data.WorkspaceID, "Second", nil) + + if err := NewItemCRUDService(db).DeleteSingle(firstID); err != nil { + t.Fatalf("delete non-max item: %v", err) + } + if got := keyReservationCount(t, db, data.WorkspaceID, 1); got != 1 { + t.Fatalf("reservation rows for 1 = %d, want 1", got) + } + + nextID := createKeyNumberItem(t, db, data.WorkspaceID, "Third", nil) + if got := itemNumberByID(t, db, nextID); got != 3 { + t.Fatalf("next item number = %d, want 3 (interior gap stays unfilled)", got) + } +} + +func TestDeleteCascadeRetiresHighestDescendantNumber(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + var parentTypeID, childTypeID int + if err := db.QueryRow(` + SELECT id FROM item_types + WHERE hierarchy_level >= 0 + ORDER BY hierarchy_level, id LIMIT 1 + `).Scan(&parentTypeID); err != nil { + t.Fatalf("load parent item type: %v", err) + } + if err := db.QueryRow(` + SELECT id FROM item_types + WHERE hierarchy_level > (SELECT hierarchy_level FROM item_types WHERE id = ?) + ORDER BY hierarchy_level, id LIMIT 1 + `, parentTypeID).Scan(&childTypeID); err != nil { + t.Fatalf("load child item type: %v", err) + } + + parentID64, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Cascade parent", + ItemTypeID: &parentTypeID, + }) + if err != nil { + t.Fatalf("create parent: %v", err) + } + parentID := int(parentID64) + childID64, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Cascade child", + ItemTypeID: &childTypeID, + ParentID: &parentID, + }) + if err != nil { + t.Fatalf("create child: %v", err) + } + if got := itemNumberByID(t, db, int(childID64)); got != 2 { + t.Fatalf("child item number = %d, want 2", got) + } + + if _, err := NewItemCRUDService(db).Delete(parentID); err != nil { + t.Fatalf("cascade delete: %v", err) + } + if got := keyReservationCount(t, db, data.WorkspaceID, 2); got != 1 { + t.Fatalf("reservation rows for descendant number 2 = %d, want 1", got) + } + + nextID := createKeyNumberItem(t, db, data.WorkspaceID, "After cascade", nil) + if got := itemNumberByID(t, db, nextID); got != 3 { + t.Fatalf("next item number = %d, want 3 (descendant key retired)", got) + } +} + +func TestBulkDeleteRetiresHighestItemNumber(t *testing.T) { + env := newBulkTransitionDeleteEnv(t) + actor := AuditActor{UserID: env.adminID, Username: "bulk-admin"} + + env.createItem(t, "Bulk first", env.openStatus) + second := env.createItem(t, "Bulk second", env.openStatus) + + result, err := env.app.BulkDelete(actor, []int{second}) + if err != nil { + t.Fatalf("bulk delete: %v", err) + } + if result.DeletedCount != 1 { + t.Fatalf("bulk deleted count = %d, want 1", result.DeletedCount) + } + if got := keyReservationCount(t, env.db, env.workspace, 2); got != 1 { + t.Fatalf("reservation rows for 2 = %d, want 1", got) + } + + nextID := env.createItem(t, "Bulk third", env.openStatus) + if got := itemNumberByID(t, env.db, nextID); got != 3 { + t.Fatalf("next item number = %d, want 3 (bulk delete retired the key)", got) + } +} diff --git a/internal/services/item_hierarchy_count_scope_test.go b/internal/services/item_hierarchy_count_scope_test.go index fda168f..a7e714b 100644 --- a/internal/services/item_hierarchy_count_scope_test.go +++ b/internal/services/item_hierarchy_count_scope_test.go @@ -83,9 +83,9 @@ func (f *hierarchyCountFixture) insertWorklog(t *testing.T, itemID, durationMinu customerID := testutils.InsertID(t, f.db, `INSERT INTO customer_organisations (name) VALUES (?)`, fmt.Sprintf("Customer %d", itemID)) projectID := testutils.InsertID(t, f.db, `INSERT INTO time_projects (customer_id, name) VALUES (?, ?)`, customerID, fmt.Sprintf("Project %d", itemID)) if _, err := f.db.Exec(` - INSERT INTO time_worklogs (project_id, customer_id, item_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) - VALUES (?, ?, ?, 'Work', 100, 100, 200, ?, 100, 100) - `, projectID, customerID, itemID, durationMinutes); err != nil { + INSERT INTO time_worklogs (project_id, item_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) + VALUES (?, ?, 'Work', 100, 100, 200, ?, 100, 100) + `, projectID, itemID, durationMinutes); err != nil { t.Fatalf("insert worklog for item %d: %v", itemID, err) } } diff --git a/internal/services/item_key_number_helpers_test.go b/internal/services/item_key_number_helpers_test.go new file mode 100644 index 0000000..eda89f5 --- /dev/null +++ b/internal/services/item_key_number_helpers_test.go @@ -0,0 +1,43 @@ +//go:build test + +package services + +import ( + "testing" + + "windshift/internal/database" +) + +func createKeyNumberItem(t *testing.T, db database.Database, workspaceID int, title string, parentID *int) int { + t.Helper() + itemID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: workspaceID, + Title: title, + ParentID: parentID, + }) + if err != nil { + t.Fatalf("create item %q: %v", title, err) + } + return int(itemID) +} + +func itemNumberByID(t *testing.T, db database.Database, itemID int) int { + t.Helper() + var number int + if err := db.QueryRow(`SELECT workspace_item_number FROM items WHERE id = ?`, itemID).Scan(&number); err != nil { + t.Fatalf("load item number for item %d: %v", itemID, err) + } + return number +} + +func keyReservationCount(t *testing.T, db database.Database, workspaceID, number int) int { + t.Helper() + var count int + if err := db.QueryRow(` + SELECT COUNT(*) FROM item_key_reservations + WHERE workspace_id = ? AND workspace_item_number = ? + `, workspaceID, number).Scan(&count); err != nil { + t.Fatalf("count key reservations for %d-%d: %v", workspaceID, number, err) + } + return count +} diff --git a/internal/services/item_pinned_number_test.go b/internal/services/item_pinned_number_test.go new file mode 100644 index 0000000..67afebc --- /dev/null +++ b/internal/services/item_pinned_number_test.go @@ -0,0 +1,118 @@ +//go:build test + +package services + +import ( + "testing" + + "windshift/internal/testutils" +) + +func TestCreateItemHonorsPinnedWorkspaceItemNumber(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + pinned := 30 + var outcome PinnedItemNumberOutcome + itemID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Pinned item", + PinnedItemNumber: &pinned, + PinnedItemNumberResult: &outcome, + }) + if err != nil { + t.Fatalf("create pinned item: %v", err) + } + if got := itemNumberByID(t, db, int(itemID)); got != 30 { + t.Fatalf("pinned item number = %d, want 30", got) + } + if outcome != (PinnedItemNumberOutcome{Requested: 30, Assigned: 30}) { + t.Fatalf("outcome = %+v, want requested/assigned 30 without fallback", outcome) + } + + nextID := createKeyNumberItem(t, db, data.WorkspaceID, "After pin", nil) + if got := itemNumberByID(t, db, nextID); got != 31 { + t.Fatalf("next item number = %d, want 31 (MAX+1 above the pinned number)", got) + } +} + +func TestCreateItemFallsBackWhenPinnedWorkspaceItemNumberTaken(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + pinned := 5 + existingID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Existing five", + PinnedItemNumber: &pinned, + }) + if err != nil { + t.Fatalf("create existing pinned item: %v", err) + } + + var outcome PinnedItemNumberOutcome + itemID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Collides with five", + PinnedItemNumber: &pinned, + PinnedItemNumberResult: &outcome, + }) + if err != nil { + t.Fatalf("create colliding pinned item: %v", err) + } + if got := itemNumberByID(t, db, int(itemID)); got != 6 { + t.Fatalf("colliding item number = %d, want 6", got) + } + if outcome != (PinnedItemNumberOutcome{Requested: 5, Assigned: 6, FellBack: true}) { + t.Fatalf("outcome = %+v, want requested 5 assigned 6 with fallback", outcome) + } + if got := itemNumberByID(t, db, int(existingID)); got != 5 { + t.Fatalf("existing item number = %d, want unchanged 5", got) + } +} + +func TestCreateItemFallsBackWhenPinnedWorkspaceItemNumberReserved(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + // A deleted item retires its number, so the pinned number is no longer free + // even though no item holds it. + retired := 9 + retiredID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Retired nine", + PinnedItemNumber: &retired, + }) + if err != nil { + t.Fatalf("create retired item: %v", err) + } + if err := NewItemCRUDService(db).DeleteSingle(int(retiredID)); err != nil { + t.Fatalf("delete retired item: %v", err) + } + if got := keyReservationCount(t, db, data.WorkspaceID, 9); got != 1 { + t.Fatalf("reservation rows for 9 = %d, want 1", got) + } + + var outcome PinnedItemNumberOutcome + itemID, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "Reuses retired nine", + PinnedItemNumber: &retired, + PinnedItemNumberResult: &outcome, + }) + if err != nil { + t.Fatalf("create item pinned to reserved number: %v", err) + } + if got := itemNumberByID(t, db, int(itemID)); got != 10 { + t.Fatalf("item number = %d, want 10", got) + } + if outcome != (PinnedItemNumberOutcome{Requested: 9, Assigned: 10, FellBack: true}) { + t.Fatalf("outcome = %+v, want requested 9 assigned 10 with fallback", outcome) + } +} diff --git a/internal/services/item_workspace_move_service_test.go b/internal/services/item_workspace_move_service_test.go index baacc4a..6de48db 100644 --- a/internal/services/item_workspace_move_service_test.go +++ b/internal/services/item_workspace_move_service_test.go @@ -121,9 +121,12 @@ func TestItemWorkspaceMovePreviewsAndCommitsExactPolicy(t *testing.T) { t.Fatalf("attach source label: %v", err) } } - milestoneID := moveInsertID(t, tdb, `INSERT INTO milestones (name, is_global) VALUES ('Source milestone', true) RETURNING id`) - if _, err := tdb.Exec(`INSERT INTO item_milestones (item_id, milestone_id) VALUES (?, ?)`, itemID, milestoneID); err != nil { - t.Fatalf("attach milestone: %v", err) + globalMilestoneID := moveInsertID(t, tdb, `INSERT INTO milestones (name, is_global) VALUES ('Global milestone', true) RETURNING id`) + workspaceMilestoneID := moveInsertID(t, tdb, `INSERT INTO milestones (name, is_global, workspace_id) VALUES ('Source milestone', false, ?) RETURNING id`, seed.WorkspaceID) + for _, milestoneID := range []int{globalMilestoneID, workspaceMilestoneID} { + if _, err := tdb.Exec(`INSERT INTO item_milestones (item_id, milestone_id) VALUES (?, ?)`, itemID, milestoneID); err != nil { + t.Fatalf("attach milestone: %v", err) + } } if _, err := tdb.Exec(`INSERT INTO comments (item_id, author_id, content) VALUES (?, ?, 'travels with item')`, itemID, seed.UserID); err != nil { t.Fatalf("insert comment: %v", err) @@ -214,12 +217,15 @@ func TestItemWorkspaceMovePreviewsAndCommitsExactPolicy(t *testing.T) { t.Fatalf("workspace-scoped fields were not cleared") } - var labelCount, milestoneCount, commentCount, historyCount int + var labelCount, globalMilestoneCount, workspaceMilestoneCount, commentCount, historyCount, milestoneHistoryCount int if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_labels WHERE item_id = ? AND label_id IN (?, ?)`, itemID, sourceKeepLabelID, sourceDropLabelID).Scan(&labelCount); err != nil || labelCount != 2 { t.Fatalf("preserved global labels = %d, %v; want 2", labelCount, err) } - if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_milestones WHERE item_id = ?`, itemID).Scan(&milestoneCount); err != nil || milestoneCount != 0 { - t.Fatalf("milestone count = %d, %v", milestoneCount, err) + if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_milestones im JOIN milestones m ON m.id = im.milestone_id WHERE im.item_id = ? AND m.is_global = true`, itemID).Scan(&globalMilestoneCount); err != nil || globalMilestoneCount != 1 { + t.Fatalf("global milestone count = %d, %v; want 1", globalMilestoneCount, err) + } + if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_milestones im JOIN milestones m ON m.id = im.milestone_id WHERE im.item_id = ? AND m.is_global = false`, itemID).Scan(&workspaceMilestoneCount); err != nil || workspaceMilestoneCount != 0 { + t.Fatalf("workspace milestone count = %d, %v; want 0", workspaceMilestoneCount, err) } if err := tdb.QueryRow(`SELECT COUNT(*) FROM comments WHERE item_id = ?`, itemID).Scan(&commentCount); err != nil || commentCount != 1 { t.Fatalf("comment count = %d, %v", commentCount, err) @@ -227,6 +233,9 @@ func TestItemWorkspaceMovePreviewsAndCommitsExactPolicy(t *testing.T) { if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_history WHERE item_id = ? AND field_name = 'workspace_move' AND new_value LIKE '%DST-9%'`, itemID).Scan(&historyCount); err != nil || historyCount != 1 { t.Fatalf("move history count = %d, %v", historyCount, err) } + if err := tdb.QueryRow(`SELECT COUNT(*) FROM item_history WHERE item_id = ? AND field_name = 'milestones' AND new_value = ?`, itemID, fmt.Sprintf("%d", globalMilestoneID)).Scan(&milestoneHistoryCount); err != nil || milestoneHistoryCount != 1 { + t.Fatalf("milestone history count = %d, %v; want 1", milestoneHistoryCount, err) + } tx, err := db.Begin() if err != nil { diff --git a/internal/services/knowledge_retrieval_docmost_test.go b/internal/services/knowledge_retrieval_docmost_test.go new file mode 100644 index 0000000..2055b78 --- /dev/null +++ b/internal/services/knowledge_retrieval_docmost_test.go @@ -0,0 +1,177 @@ +//go:build test + +package services + +import ( + "context" + "errors" + "testing" + + "windshift/internal/integrations/docmost" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +type fakeDocmostKnowledgeSource struct { + baseURL string + token string + err error + invalidated int + verified int +} + +func (f *fakeDocmostKnowledgeSource) Credential(context.Context, int) (string, string, error) { + return f.baseURL, f.token, f.err +} +func (f *fakeDocmostKnowledgeSource) MarkInvalid(int) { f.invalidated++ } +func (f *fakeDocmostKnowledgeSource) MarkVerified(int) { f.verified++ } + +type fakeKnowledgeSearcher struct { + hits []docmost.SearchResult + err error + calls int +} + +func (f *fakeKnowledgeSearcher) Search(context.Context, string, string, string, int) ([]docmost.SearchResult, error) { + f.calls++ + return f.hits, f.err +} + +func newDocmostRetrieval(t *testing.T, source DocmostKnowledgeSource, searcher DocmostSearcher) *KnowledgeRetrievalService { + t.Helper() + tdb := testutils.CreateTestDB(t, true) + if tdb.Engine == "sqlite" { + t.Cleanup(func() { _ = tdb.Close() }) + } + svc := NewKnowledgeRetrievalService(tdb.DB, NewPagePermissionService(tdb.DB, nil)) + svc.SetDocmost(source, searcher) + return svc +} + +func TestKnowledgeRetrieval_DocmostConnected(t *testing.T) { + source := &fakeDocmostKnowledgeSource{baseURL: "https://wiki.example.com", token: "tok"} + searcher := &fakeKnowledgeSearcher{hits: []docmost.SearchResult{ + {ID: "p1", SlugID: "AbC", Title: "Runbook", SpaceSlug: "eng", Highlight: "the deploy steps", Rank: 0.9}, + }} + svc := newDocmostRetrieval(t, source, searcher) + + response, err := svc.SearchWithSources(SearchInput{ + Ctx: context.Background(), UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourceDocmost}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Results) != 1 { + t.Fatalf("got %d results, want 1", len(response.Results)) + } + got := response.Results[0] + if got.Source != KnowledgeSourceDocmost || got.URL != "https://wiki.example.com/s/eng/p/AbC" { + t.Fatalf("result = %+v", got) + } + if got.Snippet != "the deploy steps" { + t.Fatalf("snippet = %q, want tags stripped", got.Snippet) + } + if len(response.Sources) != 1 || response.Sources[0].State != KnowledgeSourceStateOK { + t.Fatalf("sources = %+v, want docmost ok", response.Sources) + } + if source.verified != 1 { + t.Fatalf("MarkVerified calls = %d, want 1", source.verified) + } +} + +func TestKnowledgeRetrieval_DocmostNotConnected(t *testing.T) { + source := &fakeDocmostKnowledgeSource{err: repository.ErrNotFound} + searcher := &fakeKnowledgeSearcher{} + svc := newDocmostRetrieval(t, source, searcher) + + response, err := svc.SearchWithSources(SearchInput{ + UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourceDocmost}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Results) != 0 { + t.Fatalf("got %d results, want 0", len(response.Results)) + } + if response.Sources[0].State != KnowledgeSourceStateNotConnected { + t.Fatalf("state = %q, want not_connected", response.Sources[0].State) + } + if searcher.calls != 0 { + t.Fatalf("searcher called %d times without a credential", searcher.calls) + } +} + +func TestKnowledgeRetrieval_DocmostRejectedTokenFlagsReconnect(t *testing.T) { + source := &fakeDocmostKnowledgeSource{baseURL: "https://wiki.example.com", token: "stale"} + searcher := &fakeKnowledgeSearcher{err: docmost.ErrUnauthorized} + svc := newDocmostRetrieval(t, source, searcher) + + response, err := svc.SearchWithSources(SearchInput{ + UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourceDocmost}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Sources[0].State != KnowledgeSourceStateReconnectRequired { + t.Fatalf("state = %q, want reconnect_required", response.Sources[0].State) + } + if source.invalidated != 1 { + t.Fatalf("MarkInvalid calls = %d, want 1", source.invalidated) + } +} + +func TestKnowledgeRetrieval_DocmostNotConfiguredIsNotReported(t *testing.T) { + source := &fakeDocmostKnowledgeSource{err: ErrDocmostNotConfigured} + searcher := &fakeKnowledgeSearcher{} + svc := newDocmostRetrieval(t, source, searcher) + + response, err := svc.SearchWithSources(SearchInput{ + UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourceDocmost}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Sources) != 0 { + t.Fatalf("sources = %+v, want no docmost entry for an unconfigured deployment", response.Sources) + } + if searcher.calls != 0 { + t.Fatalf("searcher called %d times", searcher.calls) + } +} + +func TestKnowledgeRetrieval_DocmostSourceWhitelistExcludesIt(t *testing.T) { + source := &fakeDocmostKnowledgeSource{baseURL: "https://wiki.example.com", token: "tok"} + searcher := &fakeKnowledgeSearcher{} + svc := newDocmostRetrieval(t, source, searcher) + + if _, err := svc.SearchWithSources(SearchInput{ + UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourcePage}, + }); err != nil { + t.Fatalf("search: %v", err) + } + if searcher.calls != 0 { + t.Fatalf("docmost searched %d times when excluded", searcher.calls) + } +} + +func TestKnowledgeRetrieval_DocmostReconnectStateSkipsSearch(t *testing.T) { + source := &fakeDocmostKnowledgeSource{err: ErrDocmostReconnect} + searcher := &fakeKnowledgeSearcher{} + svc := newDocmostRetrieval(t, source, searcher) + + response, err := svc.SearchWithSources(SearchInput{ + UserID: 1, WorkspaceID: 2, Query: "deploy", Sources: []string{KnowledgeSourceDocmost}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Sources[0].State != KnowledgeSourceStateReconnectRequired { + t.Fatalf("state = %q, want reconnect_required", response.Sources[0].State) + } + if searcher.calls != 0 { + t.Fatalf("searcher called %d times for an invalidated credential", searcher.calls) + } + if !errors.Is(source.err, ErrDocmostReconnect) { + t.Fatal("fixture no longer returns ErrDocmostReconnect") + } +} diff --git a/internal/services/knowledge_retrieval_test.go b/internal/services/knowledge_retrieval_test.go index 36ea586..3e32260 100644 --- a/internal/services/knowledge_retrieval_test.go +++ b/internal/services/knowledge_retrieval_test.go @@ -153,19 +153,19 @@ func TestKnowledgeRetrieval_RespectsSourceWhitelist(t *testing.T) { t.Fatalf("create: %v", err) } - // Restricting to "logbook" should return zero hits even though a page - // exists — Phase 1 has no logbook adapter wired. + // Restricting to an unknown source returns zero hits even though a page + // exists. results, err := retrieval.Search(SearchInput{ UserID: 1, WorkspaceID: 1, Query: "hello", - Sources: []string{"logbook"}, + Sources: []string{"unknown"}, }) if err != nil { t.Fatalf("search: %v", err) } if len(results) != 0 { - t.Errorf("logbook-only search should return 0, got %d", len(results)) + t.Errorf("unknown-only search should return 0, got %d", len(results)) } // Restricting to "page" still returns hits. diff --git a/internal/services/magic_link_test.go b/internal/services/magic_link_test.go index 5809984..26db816 100644 --- a/internal/services/magic_link_test.go +++ b/internal/services/magic_link_test.go @@ -14,13 +14,15 @@ import ( // the verify URL the customer receives without touching SMTP. type capturingMagicLinkSender struct { templateName string + locale string data any } func (c *capturingMagicLinkSender) IsSMTPConfigured() bool { return true } -func (c *capturingMagicLinkSender) SendTransactional(_ string, templateName string, data any) error { +func (c *capturingMagicLinkSender) SendTransactional(_ string, locale, templateName string, data any) error { c.templateName = templateName + c.locale = locale c.data = data return nil } @@ -48,9 +50,12 @@ func TestSendMagicLinkEmailEmbedsValidatedNextDestination(t *testing.T) { sender := &capturingMagicLinkSender{} service := NewMagicLinkService(nil, sender, "https://portal.example.com") - if err := service.SendMagicLinkEmail("customer@example.com", "Customer", "tok", "acme", tc.next); err != nil { + if err := service.SendMagicLinkEmail("customer@example.com", "Customer", "tok", "acme", tc.next, "en"); err != nil { t.Fatalf("SendMagicLinkEmail: %v", err) } + if sender.locale != "en" { + t.Fatalf("locale = %q, want en", sender.locale) + } if sender.templateName != emailutil.TemplateMagicLink { t.Fatalf("template = %q, want %q", sender.templateName, emailutil.TemplateMagicLink) } diff --git a/internal/services/mention_email_service_test.go b/internal/services/mention_email_service_test.go index 028e63f..90fab5b 100644 --- a/internal/services/mention_email_service_test.go +++ b/internal/services/mention_email_service_test.go @@ -29,7 +29,7 @@ type fakeMentionSend struct { func (f *fakeMentionEmailSender) IsSMTPConfigured() bool { return f.configured } -func (f *fakeMentionEmailSender) SendTransactionalContext(_ context.Context, toEmail, templateName string, data any) error { +func (f *fakeMentionEmailSender) SendTransactionalContext(_ context.Context, toEmail, _ string, templateName string, data any) error { if f.sendErr != nil { return f.sendErr } diff --git a/internal/services/mention_service_test.go b/internal/services/mention_service_test.go index 04a3f0d..27f33d5 100644 --- a/internal/services/mention_service_test.go +++ b/internal/services/mention_service_test.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "testing" "windshift/internal/database" @@ -89,117 +90,170 @@ func setupMentionTestEnv(t *testing.T, db database.Database) mentionTestEnv { } } -func TestMentionService_ExtractMentionIdentifiers(t *testing.T) { +func TestMentionService_ExtractMentionRefs(t *testing.T) { db := createMentionTestDB(t) service := NewMentionService(db, nil, nil) t.Run("ExtractUsername", func(t *testing.T) { content := "Hello @johndoe, how are you?" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier, got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) } - if identifiers[0] != "johndoe" { - t.Errorf("Expected 'johndoe', got '%s'", identifiers[0]) + if refs[0].UserID != 0 || refs[0].Identifier != "johndoe" { + t.Errorf("Expected legacy ref 'johndoe', got %+v", refs[0]) } }) t.Run("ExtractQuotedDisplayName", func(t *testing.T) { content := "Hello @\"John Doe\", how are you?" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier, got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) } - if identifiers[0] != "John Doe" { - t.Errorf("Expected 'John Doe', got '%s'", identifiers[0]) + if refs[0].UserID != 0 || refs[0].Identifier != "John Doe" { + t.Errorf("Expected legacy ref 'John Doe', got %+v", refs[0]) + } + }) + + t.Run("ExtractIdToken", func(t *testing.T) { + content := "Hello @{John Doe#42}, how are you?" + refs := service.ExtractMentionRefs(content) + + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) + } + if refs[0].UserID != 42 { + t.Errorf("Expected user id 42, got %d", refs[0].UserID) + } + if refs[0].Identifier != "John Doe" { + t.Errorf("Expected display name 'John Doe', got %q", refs[0].Identifier) + } + }) + + t.Run("ExtractIdTokenWithHashInName", func(t *testing.T) { + content := "Hi @{Ticket #1 Owner#7}!" + refs := service.ExtractMentionRefs(content) + + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) + } + if refs[0].UserID != 7 || refs[0].Identifier != "Ticket #1 Owner" { + t.Errorf("Expected id 7 and name 'Ticket #1 Owner', got %+v", refs[0]) + } + }) + + t.Run("DeduplicatesById", func(t *testing.T) { + content := "@{John Doe#42} and @{J. Doe#42}" + refs := service.ExtractMentionRefs(content) + + if len(refs) != 1 { + t.Fatalf("Expected 1 ref (deduplicated by id), got %d", len(refs)) } }) t.Run("ExtractMultipleMentions", func(t *testing.T) { content := "Hello @johndoe and @janedoe!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 2 { - t.Fatalf("Expected 2 identifiers, got %d", len(identifiers)) + if len(refs) != 2 { + t.Fatalf("Expected 2 refs, got %d", len(refs)) } }) t.Run("DeduplicateMentions", func(t *testing.T) { content := "Hello @johndoe, @johndoe again!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier (deduplicated), got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref (deduplicated), got %d", len(refs)) } }) t.Run("MixedFormats", func(t *testing.T) { content := "Hello @johndoe and @\"Jane Doe\"!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 2 { - t.Fatalf("Expected 2 identifiers, got %d", len(identifiers)) + if len(refs) != 2 { + t.Fatalf("Expected 2 refs, got %d", len(refs)) } }) t.Run("NoMentions", func(t *testing.T) { content := "Hello, how are you?" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs(content) - if len(identifiers) != 0 { - t.Errorf("Expected 0 identifiers, got %d", len(identifiers)) + if len(refs) != 0 { + t.Errorf("Expected 0 refs, got %d", len(refs)) } }) t.Run("EmptyContent", func(t *testing.T) { - content := "" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs("") - if len(identifiers) != 0 { - t.Errorf("Expected 0 identifiers, got %d", len(identifiers)) + if len(refs) != 0 { + t.Errorf("Expected 0 refs, got %d", len(refs)) } }) t.Run("UsernameWithDots", func(t *testing.T) { - content := "Hello @john.doe.jr!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs("Hello @john.doe.jr!") - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier, got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) } - if identifiers[0] != "john.doe.jr" { - t.Errorf("Expected 'john.doe.jr', got '%s'", identifiers[0]) + if refs[0].Identifier != "john.doe.jr" { + t.Errorf("Expected 'john.doe.jr', got '%s'", refs[0].Identifier) } }) t.Run("UsernameWithUnderscores", func(t *testing.T) { - content := "Hello @john_doe!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs("Hello @john_doe!") - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier, got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) } - if identifiers[0] != "john_doe" { - t.Errorf("Expected 'john_doe', got '%s'", identifiers[0]) + if refs[0].Identifier != "john_doe" { + t.Errorf("Expected 'john_doe', got '%s'", refs[0].Identifier) } }) t.Run("UsernameWithDashes", func(t *testing.T) { - content := "Hello @john-doe!" - identifiers := service.ExtractMentionIdentifiers(content) + refs := service.ExtractMentionRefs("Hello @john-doe!") - if len(identifiers) != 1 { - t.Fatalf("Expected 1 identifier, got %d", len(identifiers)) + if len(refs) != 1 { + t.Fatalf("Expected 1 ref, got %d", len(refs)) } - if identifiers[0] != "john-doe" { - t.Errorf("Expected 'john-doe', got '%s'", identifiers[0]) + if refs[0].Identifier != "john-doe" { + t.Errorf("Expected 'john-doe', got '%s'", refs[0].Identifier) } }) } +// ensureDuplicateDisplayNameUsers creates (or reuses) two active users that +// share the display name "Dup Person" and returns their ids. Idempotent so the +// id and ambiguity subtests stay order-independent. +func ensureDuplicateDisplayNameUsers(t *testing.T, db database.Database) (int, int) { + t.Helper() + + ensure := func(username, email string) int { + var id int + err := db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&id) + if err == nil { + return id + } + return testutils.InsertID(t, db, ` + INSERT INTO users (username, first_name, last_name, email, is_active, created_at, updated_at) + VALUES (?, 'Dup', 'Person', ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, username, email) + } + + return ensure("dup.person.a", "dup.person.a@example.com"), ensure("dup.person.b", "dup.person.b@example.com") +} + func TestMentionService_ProcessMentions(t *testing.T) { db := createMentionTestDB(t) @@ -410,6 +464,119 @@ func TestMentionService_ProcessMentions(t *testing.T) { t.Errorf("Expected 1 mention from display name, got %d", count) } }) + + t.Run("ResolvesEmbeddedIdWhenDisplayNameIsDuplicated", func(t *testing.T) { + db.Exec("DELETE FROM mentions") + + dupA, _ := ensureDuplicateDisplayNameUsers(t, db) + sourceID := env.CommentID + 800 + + params := ProcessMentionsParams{ + SourceType: "comment", + SourceID: sourceID, + Content: fmt.Sprintf("Hello @{Dup Person#%d}!", dupA), + ItemID: env.ItemID, + WorkspaceID: env.WorkspaceID, + ActorUserID: env.UserID2, + } + + if err := service.ProcessMentions(params); err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + var count int + if err := db.QueryRow(` + SELECT COUNT(*) FROM mentions + WHERE source_type = ? AND source_id = ? AND mentioned_user_id = ? + `, "comment", sourceID, dupA).Scan(&count); err != nil { + t.Fatalf("Failed to query mentions: %v", err) + } + if count != 1 { + t.Errorf("Expected the id-embedded user to be mentioned exactly once, got %d", count) + } + }) + + t.Run("SkipsAmbiguousDisplayNameMention", func(t *testing.T) { + db.Exec("DELETE FROM mentions") + + ensureDuplicateDisplayNameUsers(t, db) + sourceID := env.CommentID + 900 + + params := ProcessMentionsParams{ + SourceType: "comment", + SourceID: sourceID, + Content: "Hello @\"Dup Person\"!", + ItemID: env.ItemID, + WorkspaceID: env.WorkspaceID, + ActorUserID: env.UserID2, + } + + if err := service.ProcessMentions(params); err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + var count int + if err := db.QueryRow(` + SELECT COUNT(*) FROM mentions WHERE source_type = ? AND source_id = ? + `, "comment", sourceID).Scan(&count); err != nil { + t.Fatalf("Failed to query mentions: %v", err) + } + if count != 0 { + t.Errorf("Expected no mention for an ambiguous display name, got %d", count) + } + }) + + t.Run("SkipsIdTokenForInactiveUser", func(t *testing.T) { + db.Exec("DELETE FROM mentions") + + sourceID := env.CommentID + 1000 + params := ProcessMentionsParams{ + SourceType: "comment", + SourceID: sourceID, + Content: fmt.Sprintf("Hello @{Inactive User#%d}!", env.UserID3), + ItemID: env.ItemID, + WorkspaceID: env.WorkspaceID, + ActorUserID: env.UserID2, + } + + if err := service.ProcessMentions(params); err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM mentions WHERE source_id = ?`, sourceID).Scan(&count); err != nil { + t.Fatalf("Failed to query mentions: %v", err) + } + if count != 0 { + t.Errorf("Expected no mention for an inactive user, got %d", count) + } + }) + + t.Run("SkipsIdTokenForUnknownUser", func(t *testing.T) { + db.Exec("DELETE FROM mentions") + + sourceID := env.CommentID + 1100 + params := ProcessMentionsParams{ + SourceType: "comment", + SourceID: sourceID, + Content: "Hello @{Nobody Here#99999}!", + ItemID: env.ItemID, + WorkspaceID: env.WorkspaceID, + ActorUserID: env.UserID2, + } + + if err := service.ProcessMentions(params); err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM mentions WHERE source_id = ?`, sourceID).Scan(&count); err != nil { + t.Fatalf("Failed to query mentions: %v", err) + } + if count != 0 { + t.Errorf("Expected no mention for an unknown id, got %d", count) + } + }) } func TestMentionService_DeleteMentionsForSource(t *testing.T) { @@ -533,8 +700,8 @@ func TestMentionPattern(t *testing.T) { if len(matches) != 1 { t.Fatalf("Expected 1 match, got %d", len(matches)) } - if matches[0][1] != "johndoe" { - t.Errorf("Expected 'johndoe', got '%s'", matches[0][1]) + if matches[0][3] != "johndoe" { + t.Errorf("Expected 'johndoe', got '%s'", matches[0][3]) } }) @@ -543,8 +710,8 @@ func TestMentionPattern(t *testing.T) { if len(matches) != 1 { t.Fatalf("Expected 1 match, got %d", len(matches)) } - if matches[0][2] != "John Doe" { - t.Errorf("Expected 'John Doe', got '%s'", matches[0][2]) + if matches[0][4] != "John Doe" { + t.Errorf("Expected 'John Doe', got '%s'", matches[0][4]) } }) @@ -604,8 +771,35 @@ func TestMentionPattern(t *testing.T) { if len(matches) != 1 { t.Fatalf("Expected 1 match, got %d", len(matches)) } - if matches[0][1] != "johndoe" { - t.Errorf("Expected 'johndoe', got '%s'", matches[0][1]) + if matches[0][3] != "johndoe" { + t.Errorf("Expected 'johndoe', got '%s'", matches[0][3]) + } + }) + + t.Run("MatchesIdToken", func(t *testing.T) { + matches := MentionPattern.FindAllStringSubmatch("hello @{John Doe#42} world", -1) + if len(matches) != 1 { + t.Fatalf("Expected 1 match, got %d", len(matches)) + } + if matches[0][1] != "John Doe" { + t.Errorf("Expected 'John Doe', got '%s'", matches[0][1]) + } + if matches[0][2] != "42" { + t.Errorf("Expected id '42', got '%s'", matches[0][2]) + } + }) + + t.Run("MatchesIdTokenAtStart", func(t *testing.T) { + matches := MentionPattern.FindAllStringSubmatch("@{John Doe#7} hello", -1) + if len(matches) != 1 { + t.Fatalf("Expected 1 match, got %d", len(matches)) + } + }) + + t.Run("DoesNotMatchEmptyIdToken", func(t *testing.T) { + matches := MentionPattern.FindAllStringSubmatch("hello @{John Doe} world", -1) + if len(matches) != 0 { + t.Fatalf("Expected 0 matches without an id, got %d", len(matches)) } }) @@ -615,8 +809,8 @@ func TestMentionPattern(t *testing.T) { if len(matches) != 1 { t.Fatalf("Expected 1 match, got %d", len(matches)) } - if matches[0][1] != "johndoe" { - t.Errorf("Expected 'johndoe', got '%s'", matches[0][1]) + if matches[0][3] != "johndoe" { + t.Errorf("Expected 'johndoe', got '%s'", matches[0][3]) } }) } diff --git a/internal/services/n_plus_one_batch_test.go b/internal/services/n_plus_one_batch_test.go new file mode 100644 index 0000000..7a63368 --- /dev/null +++ b/internal/services/n_plus_one_batch_test.go @@ -0,0 +1,214 @@ +//go:build test + +package services + +import ( + "fmt" + "testing" + "time" + + "windshift/internal/models" + "windshift/internal/testutils" +) + +// WI-1909: the service paths that used to fan out one query per row must now +// issue a constant number of queries as their input grows. + +func TestGetRootIssuesConstantQueriesRegardlessOfDepth(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + insertItem := func(number int, parentID *int) int { + t.Helper() + return testutils.InsertID(t, db, ` + INSERT INTO items ( + workspace_id, workspace_item_number, title, description, parent_id, + status_id, priority_id, frac_index, created_at, updated_at + ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?) + `, seed.WorkspaceID, number, "root walk", parentID, + seed.StatusID, seed.PriorityID, testutils.NextTestFracIndex(), + time.Now().UTC(), time.Now().UTC()) + } + + counting := testutils.CountQueries(db) + service := NewHierarchyService(counting) + + buildChain := func(depth, firstNumber int) int { + t.Helper() + var parentID *int + leaf := 0 + for i := 0; i < depth; i++ { + id := insertItem(firstNumber+i, parentID) + leaf = id + next := id + parentID = &next + } + return leaf + } + + shallowLeaf := buildChain(3, 1) + counting.ResetQueries() + shallowRoot, err := service.GetRoot(shallowLeaf) + if err != nil { + t.Fatalf("GetRoot (depth 3): %v", err) + } + if shallowRoot == nil { + t.Fatal("GetRoot (depth 3) returned nil") + } + shallowQueries := counting.Queries() + + deepLeaf := buildChain(25, 100) + counting.ResetQueries() + deepRoot, err := service.GetRoot(deepLeaf) + if err != nil { + t.Fatalf("GetRoot (depth 25): %v", err) + } + if deepRoot == nil { + t.Fatal("GetRoot (depth 25) returned nil") + } + deepQueries := counting.Queries() + + if shallowQueries != deepQueries { + t.Fatalf("GetRoot issued %d queries at depth 3 and %d at depth 25: the per-level walk is back", + shallowQueries, deepQueries) + } +} + +func TestListExampleResultsIssuesConstantQueries(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + setID := testutils.InsertID(t, db, + `INSERT INTO test_sets (workspace_id, name, description) VALUES (?, ?, '')`, + seed.WorkspaceID, "n+1 set") + testCaseID := testutils.InsertID(t, db, + `INSERT INTO test_cases (workspace_id, title, format, priority) VALUES (?, ?, 'bdd', 'medium')`, + seed.WorkspaceID, "n+1 case") + runID := testutils.InsertID(t, db, + `INSERT INTO test_runs (workspace_id, set_id, name) VALUES (?, ?, ?)`, + seed.WorkspaceID, setID, "n+1 run") + + addExample := func(index int) { + t.Helper() + resultID := testutils.InsertID(t, db, ` + INSERT INTO test_example_results (run_id, test_case_id, example_index, row_values, status) + VALUES (?, ?, ?, '{}', 'not_run') + `, runID, testCaseID, index) + testutils.InsertID(t, db, ` + INSERT INTO test_example_step_results (example_result_id, step_number, status, actual_result, notes) + VALUES (?, 1, 'passed', 'output', '') + `, resultID) + } + + addExample(0) + + counting := testutils.CountQueries(db) + service := NewTestRunService(counting) + + counting.ResetQueries() + single, err := service.ListExampleResults(runID, seed.WorkspaceID) + if err != nil { + t.Fatalf("ListExampleResults with one example: %v", err) + } + queriesForOne := counting.Queries() + if len(single) != 1 || len(single[0].StepResults) != 1 { + t.Fatalf("one-example result = %d examples, want 1 with 1 step", len(single)) + } + + const extra = 7 + for i := 1; i <= extra; i++ { + addExample(i) + } + + counting.ResetQueries() + many, err := service.ListExampleResults(runID, seed.WorkspaceID) + if err != nil { + t.Fatalf("ListExampleResults with many examples: %v", err) + } + queriesForMany := counting.Queries() + if len(many) != extra+1 { + t.Fatalf("many-example result = %d examples, want %d", len(many), extra+1) + } + for _, result := range many { + if len(result.StepResults) != 1 { + t.Fatalf("example %d has %d steps, want 1", result.ID, len(result.StepResults)) + } + } + + if queriesForOne != queriesForMany { + t.Fatalf("ListExampleResults issued %d queries for 1 example and %d for %d: the per-example query is back", + queriesForOne, queriesForMany, extra+1) + } +} + +func TestInvalidateMultipleUserCachesResolvesAgentsInOneQuery(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + + counting := testutils.CountQueries(db) + config := DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + service, err := NewPermissionService(counting, config) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + t.Cleanup(func() { _ = service.Close() }) + + insertUser := func(email string) int { + t.Helper() + return testutils.InsertID(t, db, + `INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, '')`, + email, email, email) + } + insertAgent := func(ownerID int, email string) int { + t.Helper() + return testutils.InsertID(t, db, + `INSERT INTO users (email, username, first_name, last_name, is_agent, agent_owner_user_id) VALUES (?, ?, ?, '', true, ?)`, + email, email, email, ownerID) + } + + firstOwner := insertUser("n1-owner@example.test") + firstAgent := insertAgent(firstOwner, "n1-agent@example.test") + + // Prime the cache so the invalidation has something to drop. + if err := service.storeUserPermissionCache(firstAgent, &models.UserPermissionCache{ + UserID: firstAgent, + ExpiresAt: time.Now().Add(time.Minute), + }); err != nil { + t.Fatalf("store agent snapshot: %v", err) + } + + counting.ResetQueries() + if err := service.InvalidateMultipleUserCaches([]int{firstOwner}); err != nil { + t.Fatalf("InvalidateMultipleUserCaches (one owner): %v", err) + } + queriesForOne := counting.Queries() + + if _, ok := service.cache.Get(firstAgent); ok { + t.Fatal("the owner's agent snapshot survived invalidation") + } + + owners := []int{firstOwner} + const extraOwners = 9 + for i := 0; i < extraOwners; i++ { + owner := insertUser(fmt.Sprintf("n1-owner-%d@example.test", i)) + insertAgent(owner, fmt.Sprintf("n1-agent-%d@example.test", i)) + owners = append(owners, owner) + } + + counting.ResetQueries() + if err := service.InvalidateMultipleUserCaches(owners); err != nil { + t.Fatalf("InvalidateMultipleUserCaches (many owners): %v", err) + } + queriesForMany := counting.Queries() + + if queriesForOne != queriesForMany { + t.Fatalf("invalidating 1 owner issued %d queries and %d owners issued %d: the per-owner agent lookup is back", + queriesForOne, len(owners), queriesForMany) + } +} diff --git a/internal/services/notification_digest_resilience_test.go b/internal/services/notification_digest_resilience_test.go new file mode 100644 index 0000000..5acbe97 --- /dev/null +++ b/internal/services/notification_digest_resilience_test.go @@ -0,0 +1,112 @@ +//go:build test + +package services + +import ( + "errors" + "testing" + + "windshift/internal/models" +) + +// flakyAuthorizer wraps the real authorizer and injects a snapshot failure for +// one user so the digest's per-recipient resilience can be exercised. +type flakyAuthorizer struct { + real *NotificationAuthorizer + failID int +} + +func (a flakyAuthorizer) Snapshot(userID int) (*NotificationAuthorizationSnapshot, error) { + if userID == a.failID { + return nil, errors.New("injected scope resolution failure") + } + return a.real.Snapshot(userID) +} + +func (a flakyAuthorizer) snapshotForRecipient(userID int, active bool, permissions *models.UserPermissionCache) *NotificationAuthorizationSnapshot { + return a.real.snapshotForRecipient(userID, active, permissions) +} + +// TestUnreadEmailBatchesSkipsFailingRecipientWithoutAborting is the regression +// test for one recipient's authorization failure wedging the whole digest tick: +// the healthy recipient must still be batched, and the failing recipient's rows +// must stay pending for a later retry. +func TestUnreadEmailBatchesSkipsFailingRecipientWithoutAborting(t *testing.T) { + env := newPermTestEnv(t) + failingID := env.insertUser("digest-failing@example.com") + healthyID := env.insertUser("digest-healthy@example.com") + + // The failing recipient's row sorts first so it is scanned before the + // healthy one. + if _, err := env.db.Exec(` + INSERT INTO notifications (user_id, title, message, type, timestamp, read, authorization_scope) + VALUES (?, 'failing notice', 'body', 'info', datetime('now', '-1 minute'), false, ?) + `, failingID, models.NotificationScopeSystem); err != nil { + t.Fatalf("insert failing notification: %v", err) + } + if _, err := env.db.Exec(` + INSERT INTO notifications (user_id, title, message, type, timestamp, read, authorization_scope) + VALUES (?, 'healthy notice', 'body', 'info', CURRENT_TIMESTAMP, false, ?) + `, healthyID, models.NotificationScopeSystem); err != nil { + t.Fatalf("insert healthy notification: %v", err) + } + + service := NewNotificationService(env.db, nil, env.service, DefaultNotificationServiceConfig()) + t.Cleanup(func() { + if err := service.Close(); err != nil { + t.Errorf("close notification service: %v", err) + } + }) + service.notificationAuth = flakyAuthorizer{ + real: NewNotificationAuthorizer(env.db, env.service, nil), + failID: failingID, + } + + batches, err := service.UnreadEmailBatches(100) + if err != nil { + t.Fatalf("digest aborted on one recipient's failure: %v", err) + } + assertNotificationTitles(t, batches["digest-healthy@example.com"], "healthy notice") + if _, ok := batches["digest-failing@example.com"]; ok { + t.Fatalf("failing recipient was batched despite the injected failure") + } + + // The skipped recipient's row must remain pending, not suppressed, so a + // later tick can retry it. + var state string + if err := env.db.QueryRow(`SELECT email_delivery_state FROM notifications WHERE user_id = ?`, failingID).Scan(&state); err != nil { + t.Fatalf("read failing notification state: %v", err) + } + if state != "pending" { + t.Fatalf("failing recipient state = %q, want pending", state) + } +} + +// TestUnreadEmailBatchesSurfacesSystemicRecipientFailure proves that when every +// candidate recipient fails, the tick reports an error instead of silently +// returning "nothing to send". +func TestUnreadEmailBatchesSurfacesSystemicRecipientFailure(t *testing.T) { + env := newPermTestEnv(t) + userID := env.insertUser("digest-only-failing@example.com") + if _, err := env.db.Exec(` + INSERT INTO notifications (user_id, title, message, type, timestamp, read, authorization_scope) + VALUES (?, 'notice', 'body', 'info', CURRENT_TIMESTAMP, false, ?) + `, userID, models.NotificationScopeSystem); err != nil { + t.Fatalf("insert notification: %v", err) + } + + service := NewNotificationService(env.db, nil, env.service, DefaultNotificationServiceConfig()) + t.Cleanup(func() { + if err := service.Close(); err != nil { + t.Errorf("close notification service: %v", err) + } + }) + service.notificationAuth = flakyAuthorizer{ + real: NewNotificationAuthorizer(env.db, env.service, nil), + failID: userID, + } + + if _, err := service.UnreadEmailBatches(100); err == nil { + t.Fatalf("expected an error when every candidate recipient fails") + } +} diff --git a/internal/services/notification_recipient_filtering_test.go b/internal/services/notification_recipient_filtering_test.go new file mode 100644 index 0000000..728a4dd --- /dev/null +++ b/internal/services/notification_recipient_filtering_test.go @@ -0,0 +1,144 @@ +//go:build test + +package services + +import ( + "database/sql" + "fmt" + "slices" + "strings" + "sync" + "testing" + + "windshift/internal/database" + "windshift/internal/models" +) + +// recordingDatabase records the queries issued through it so a test can assert +// the fan-out path does not fall back to per-recipient probes. +type recordingDatabase struct { + database.Database + mu sync.Mutex + queries []string +} + +func (db *recordingDatabase) record(query string) { + db.mu.Lock() + db.queries = append(db.queries, query) + db.mu.Unlock() +} + +func (db *recordingDatabase) Query(query string, args ...interface{}) (*sql.Rows, error) { + db.record(query) + return db.Database.Query(query, args...) +} + +func (db *recordingDatabase) QueryRow(query string, args ...interface{}) *sql.Row { + db.record(query) + return db.Database.QueryRow(query, args...) +} + +func (db *recordingDatabase) reset() { + db.mu.Lock() + db.queries = nil + db.mu.Unlock() +} + +func (db *recordingDatabase) countContaining(substring string) int { + db.mu.Lock() + defer db.mu.Unlock() + count := 0 + for _, query := range db.queries { + if strings.Contains(query, substring) { + count++ + } + } + return count +} + +// Regression: recipient filtering used to resolve a permission snapshot and +// probe users.is_active once per recipient, then resolve the snapshot again for +// the referenced-entity check. A multi-recipient rule with a referenced entity +// must resolve one snapshot per recipient, batch the is_active probe, and still +// return exactly the recipients that may view the referenced item. +func TestDetermineRecipientsResolvesSnapshotOncePerRecipient(t *testing.T) { + env := newPermTestEnv(t) + t.Cleanup(func() { _ = env.service.Close() }) + + ownerID := env.insertUser("fanout-owner@example.test") + workspaceID := env.insertWorkspace("FANOUT") + viewerRoleID := env.roleID("Viewer") + + allowed := []int{ + env.insertUser("fanout-a@example.test"), + env.insertUser("fanout-b@example.test"), + env.insertUser("fanout-c@example.test"), + } + for _, userID := range allowed { + env.assignUserWorkspaceRole(userID, workspaceID, viewerRoleID) + } + // The explicit Viewer assignments above gate the workspace, so this user + // has no item.view and must be filtered out. + deniedID := env.insertUser("fanout-denied@example.test") + + targetID64, err := CreateItem(env.db, ItemCreationParams{WorkspaceID: workspaceID, Title: "Fanout target"}) + if err != nil { + t.Fatalf("create referenced item: %v", err) + } + targetID := int(targetID64) + + recorder := &recordingDatabase{Database: env.db} + service := NewNotificationService(recorder, nil, env.service, DefaultNotificationServiceConfig()) + t.Cleanup(func() { _ = service.Close() }) + + // Warm every candidate's snapshot so the fan-out is a pure cache read. + for _, userID := range append(slices.Clone(allowed), deniedID) { + if _, err := env.service.GetUserEffectivePermissions(userID); err != nil { + t.Fatalf("warm snapshot for user %d: %v", userID, err) + } + } + + rule := models.NotificationEventRule{ + ID: 1, + EventType: models.EventItemUpdated, + IsEnabled: true, + CustomRecipients: fmt.Sprintf("[%d,%d,%d,%d]", allowed[0], allowed[1], allowed[2], deniedID), + } + event := &NotificationEvent{ + EventType: models.EventItemUpdated, + WorkspaceID: workspaceID, + ActorUserID: ownerID, + ItemID: targetID, + ReferencedEntityType: "item", + ReferencedEntityID: targetID, + ReferencedWorkspaceID: workspaceID, + ReferencedWorkspacePermission: models.PermissionItemView, + } + + before := env.service.GetCacheStats() + recorder.reset() + recipients := service.determineRecipients(event, &rule) + after := env.service.GetCacheStats() + + slices.Sort(recipients) + if !slices.Equal(recipients, allowed) { + t.Fatalf("recipients = %v, want %v (denied=%d)", recipients, allowed, deniedID) + } + + // One snapshot resolution per candidate, reused for the view and + // referenced-entity checks, with no rebuild. + if got := after.Misses - before.Misses; got != 0 { + t.Fatalf("snapshot rebuilds during fan-out = %d, want 0", got) + } + if got := after.Hits - before.Hits; got != 4 { + t.Fatalf("snapshot resolutions during fan-out = %d, want one per candidate (4)", got) + } + + // users.is_active is probed once, batched, never per recipient. + if got := recorder.countContaining("is_agent, is_active"); got != 1 { + t.Fatalf("batched is_agent/is_active probes = %d, want 1", got) + } + if got := recorder.countContaining("SELECT is_active FROM users WHERE id = ?"); got != 0 { + t.Fatalf("per-recipient is_active probes = %d, want 0", got) + } +} diff --git a/internal/services/notification_service_test.go b/internal/services/notification_service_test.go index 8c76447..7307983 100644 --- a/internal/services/notification_service_test.go +++ b/internal/services/notification_service_test.go @@ -63,15 +63,19 @@ func TestUnreadEmailBatchesReauthorizeWorkspaceNotifications(t *testing.T) { t.Fatalf("accessible workspaces before revocation = %v, want [%d]", accessibleBefore, allowedWorkspaceID) } - beforeDecodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before, err := service.UnreadEmailBatches(100) + before := env.service.GetCacheStats() + batchesBefore, err := service.UnreadEmailBatches(100) if err != nil { t.Fatalf("email batches before revocation: %v", err) } - if decodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - beforeDecodes; decodes != 1 { - t.Fatalf("permission scope decodes before revocation = %d, want 1 per recipient", decodes) + after := env.service.GetCacheStats() + if decodes := after.PermissionSnapshotDecodes - before.PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission scope decodes before revocation = %d, want 0", decodes) + } + if hits := after.Hits - before.Hits; hits != 1 { + t.Fatalf("permission cache hits before revocation = %d, want 1 per recipient", hits) } - assertNotificationTitles(t, before["notification-recipient@example.com"], "allowed workspace", "system notice") + assertNotificationTitles(t, batchesBefore["notification-recipient@example.com"], "allowed workspace", "system notice") if _, err := roles.RevokeFromUser(userID, allowedWorkspaceID, viewerRoleID); err != nil { t.Fatalf("revoke workspace role: %v", err) @@ -86,15 +90,19 @@ func TestUnreadEmailBatchesReauthorizeWorkspaceNotifications(t *testing.T) { if len(accessibleAfter) != 0 { t.Fatalf("accessible workspaces after revocation = %v, want none", accessibleAfter) } - afterDecodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - after, err := service.UnreadEmailBatches(100) + before = env.service.GetCacheStats() + batchesAfter, err := service.UnreadEmailBatches(100) if err != nil { t.Fatalf("email batches after revocation: %v", err) } - if decodes := env.service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - afterDecodes; decodes != 1 { - t.Fatalf("permission scope decodes after revocation = %d, want 1 per recipient", decodes) + after = env.service.GetCacheStats() + if decodes := after.PermissionSnapshotDecodes - before.PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission scope decodes after revocation = %d, want 0", decodes) + } + if hits := after.Hits - before.Hits; hits != 1 { + t.Fatalf("permission cache hits after revocation = %d, want 1 per recipient", hits) } - assertNotificationTitles(t, after["notification-recipient@example.com"], "system notice") + assertNotificationTitles(t, batchesAfter["notification-recipient@example.com"], "system notice") } func TestUnreadEmailBatchesReauthorizeGroupDerivedWorkspaceNotifications(t *testing.T) { diff --git a/internal/services/page_summaries_batch_test.go b/internal/services/page_summaries_batch_test.go new file mode 100644 index 0000000..2baa939 --- /dev/null +++ b/internal/services/page_summaries_batch_test.go @@ -0,0 +1,154 @@ +//go:build test + +package services + +import ( + "encoding/json" + "strings" + "testing" + + "windshift/internal/testutils" +) + +// The cross-workspace summaries lookup backs the mobile pages list and search +// (WI-1918). It must list every visible page with the row fields the phone +// renders, search title+body when a query is supplied, cap search results, +// skip workspaces the caller cannot see, and never leak page bodies. +func TestPageApplicationService_ListSummariesAcrossWorkspaces(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { tdb.Close() }) + data := tdb.SeedTestData(t) + + var wsB int + if err := tdb.QueryRow(` + INSERT INTO workspaces (name, key, description, active) + VALUES ('Summaries Workspace B', 'SWSB', 'summaries batch fixture', true) + RETURNING id + `).Scan(&wsB); err != nil { + t.Fatalf("create workspace B: %v", err) + } + if _, err := tdb.Exec(` + INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_at) + SELECT ?, ?, id, CURRENT_TIMESTAMP FROM workspace_roles WHERE name = 'Administrator' + `, data.UserID, wsB); err != nil { + t.Fatalf("grant workspace B role: %v", err) + } + + var wsInactive int + if err := tdb.QueryRow(` + INSERT INTO workspaces (name, key, description, active) + VALUES ('Summaries Workspace Inactive', 'SWSD', 'summaries batch fixture', false) + RETURNING id + `).Scan(&wsInactive); err != nil { + t.Fatalf("create inactive workspace: %v", err) + } + + pageService := NewPageService(tdb.GetDatabase()) + home, err := pageService.Create(data.UserID, CreatePageInput{ + WorkspaceID: data.WorkspaceID, + Title: "Home page", + Content: "home body", + IsHome: true, + }) + if err != nil { + t.Fatalf("create home: %v", err) + } + child, err := pageService.Create(data.UserID, CreatePageInput{ + WorkspaceID: data.WorkspaceID, + Title: "Child page", + Content: "child body mentions needles", + ParentID: &home.ID, + }) + if err != nil { + t.Fatalf("create child: %v", err) + } + if child.Depth != 1 { + t.Fatalf("child depth = %d, want 1", child.Depth) + } + beta, err := pageService.Create(data.UserID, CreatePageInput{ + WorkspaceID: wsB, + Title: "Beta page", + Content: "beta body", + }) + if err != nil { + t.Fatalf("create beta: %v", err) + } + if _, err := pageService.Create(data.UserID, CreatePageInput{ + WorkspaceID: wsInactive, + Title: "Hidden inactive", + Content: "hidden body", + }); err != nil { + t.Fatalf("create hidden page: %v", err) + } + + permissions, err := NewPermissionService(tdb.GetDatabase(), DefaultPermissionCacheConfig()) + if err != nil { + t.Fatalf("create permission service: %v", err) + } + application := NewPageApplicationService(pageService, NewPagePermissionService(tdb.GetDatabase(), permissions)) + + t.Run("empty query lists every visible page", func(t *testing.T) { + rows, err := application.ListSummariesAcrossWorkspaces(data.UserID, []int{ + data.WorkspaceID, wsB, data.WorkspaceID, wsInactive, 999999, + }, "", 0) + if err != nil { + t.Fatalf("ListSummariesAcrossWorkspaces: %v", err) + } + got := make(map[int]PageSummaryRow, len(rows)) + for _, row := range rows { + got[row.PageID] = row + } + if len(got) != 3 { + t.Fatalf("got %d rows, want 3: %+v", len(got), rows) + } + homeRow, ok := got[home.ID] + if !ok || homeRow.WorkspaceID != data.WorkspaceID || homeRow.Title != "Home page" || !homeRow.IsHome || homeRow.Depth != 0 { + t.Fatalf("home row = %+v", homeRow) + } + childRow, ok := got[child.ID] + if !ok || childRow.ParentID == nil || *childRow.ParentID != home.ID || childRow.Depth != 1 || childRow.IsHome { + t.Fatalf("child row = %+v", childRow) + } + if childRow.UpdatedAt.IsZero() { + t.Fatalf("child row updated_at is zero") + } + betaRow, ok := got[beta.ID] + if !ok || betaRow.WorkspaceID != wsB || betaRow.Title != "Beta page" { + t.Fatalf("beta row = %+v", betaRow) + } + + encoded, err := json.Marshal(rows) + if err != nil { + t.Fatalf("marshal rows: %v", err) + } + for _, banned := range []string{`"content"`, `"metadata"`, `"labels"`, `"slug"`} { + if strings.Contains(string(encoded), banned) { + t.Fatalf("summaries response contains %s: %s", banned, encoded) + } + } + }) + + t.Run("query searches title and body and caps results", func(t *testing.T) { + // "needles" appears only in the child body, proving the search still + // matches page bodies rather than titles alone. + rows, err := application.ListSummariesAcrossWorkspaces(data.UserID, []int{data.WorkspaceID, wsB}, "needles", 5) + if err != nil { + t.Fatalf("search summaries: %v", err) + } + if len(rows) != 1 || rows[0].PageID != child.ID { + t.Fatalf("search rows = %+v, want only the child page", rows) + } + + rows, err = application.ListSummariesAcrossWorkspaces(data.UserID, []int{data.WorkspaceID, wsB}, "page", 1) + if err != nil { + t.Fatalf("capped search: %v", err) + } + if len(rows) != 1 { + t.Fatalf("capped search returned %d rows, want 1", len(rows)) + } + }) + + if _, err := permissions.AccessibleWorkspaceIDKeys(data.UserID); err != nil { + t.Fatalf("post-read permission lookup: %v", err) + } +} diff --git a/internal/services/permission_cache_duplicate_loads_test.go b/internal/services/permission_cache_duplicate_loads_test.go new file mode 100644 index 0000000..c59d165 --- /dev/null +++ b/internal/services/permission_cache_duplicate_loads_test.go @@ -0,0 +1,148 @@ +//go:build test + +package services + +import ( + "slices" + "testing" + + "windshift/internal/testutils" +) + +// WI-1915: buildUserPermissionCache loaded group memberships and role +// assignments before the isAdmin early return and again after it, so every +// non-admin snapshot build paid two redundant queries. + +const ( + groupMembershipsQuery = "SELECT gm.group_id" + roleAssignmentsQuery = "SELECT workspace_id, role_id FROM user_workspace_roles WHERE user_id = ?" + systemAdminGrantInsert = `INSERT INTO user_global_permissions (user_id, permission_id, granted_by, granted_at) + VALUES (?, (SELECT id FROM permissions WHERE permission_key = 'system.admin'), ?, CURRENT_TIMESTAMP)` +) + +// permissionBuildFixture seeds one user with a group membership and a workspace +// role, plus the service under test wired to a query-counting database. +type permissionBuildFixture struct { + service *PermissionService + counting *testutils.QueryCountingDB + userID int + groupID int + roleID int + workspace int +} + +func newPermissionBuildFixture(t *testing.T, makeAdmin bool) permissionBuildFixture { + t.Helper() + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + seed := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + counting := testutils.CountQueries(db) + config := DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + service, err := NewPermissionService(counting, config) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + t.Cleanup(func() { _ = service.Close() }) + + userID := testutils.InsertID(t, db, + `INSERT INTO users (email, username, first_name, last_name) VALUES (?, ?, ?, '')`, + "build-loads@example.test", "build-loads", "Build") + groupID := testutils.InsertID(t, db, + `INSERT INTO groups (name, is_active) VALUES (?, true)`, "build-loads-group") + if _, err := db.Exec(`INSERT INTO group_members (group_id, user_id) VALUES (?, ?)`, groupID, userID); err != nil { + t.Fatalf("add group member: %v", err) + } + + var roleID int + if err := db.QueryRow(`SELECT id FROM workspace_roles WHERE name = 'Viewer' LIMIT 1`).Scan(&roleID); err != nil { + t.Fatalf("find Viewer role: %v", err) + } + if _, err := db.Exec( + `INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, + userID, seed.WorkspaceID, roleID, + ); err != nil { + t.Fatalf("assign workspace role: %v", err) + } + + if makeAdmin { + if _, err := db.Exec(systemAdminGrantInsert, userID, userID); err != nil { + t.Fatalf("grant system admin: %v", err) + } + } + + return permissionBuildFixture{ + service: service, + counting: counting, + userID: userID, + groupID: groupID, + roleID: roleID, + workspace: seed.WorkspaceID, + } +} + +func TestBuildUserPermissionCacheLoadsMembershipsAndRolesOnceForNonAdmin(t *testing.T) { + fixture := newPermissionBuildFixture(t, false) + + fixture.counting.ResetQueries() + snapshot, err := fixture.service.GetUserEffectivePermissions(fixture.userID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions: %v", err) + } + if snapshot.IsSystemAdmin { + t.Fatal("fixture user must not be a system admin") + } + + if got := fixture.counting.CountQueriesContaining(groupMembershipsQuery); got != 1 { + t.Fatalf("group memberships loaded %d times in one non-admin build, want 1", got) + } + if got := fixture.counting.CountQueriesContaining(roleAssignmentsQuery); got != 1 { + t.Fatalf("role assignments loaded %d times in one non-admin build, want 1", got) + } +} + +// The admin early return deliberately keeps memberships and role assignments so +// user_in_group and user_in_role conditions resolve against real data. Dropping +// the duplicate load must not empty them. +func TestBuildUserPermissionCacheKeepsMembershipsAndRolesForSystemAdmin(t *testing.T) { + fixture := newPermissionBuildFixture(t, true) + + snapshot, err := fixture.service.GetUserEffectivePermissions(fixture.userID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions: %v", err) + } + if !snapshot.IsSystemAdmin { + t.Fatal("fixture user must be a system admin") + } + + if !slices.Contains(snapshot.GroupMemberships, fixture.groupID) { + t.Fatalf("admin snapshot group memberships = %v, want to contain %d", + snapshot.GroupMemberships, fixture.groupID) + } + if !slices.Contains(snapshot.RoleAssignments[fixture.workspace], fixture.roleID) { + t.Fatalf("admin snapshot roles for workspace %d = %v, want to contain %d", + fixture.workspace, snapshot.RoleAssignments[fixture.workspace], fixture.roleID) + } +} + +// A non-admin snapshot must carry the same data as the admin one — the fix +// removes the second load, not the data. +func TestBuildUserPermissionCacheKeepsMembershipsAndRolesForNonAdmin(t *testing.T) { + fixture := newPermissionBuildFixture(t, false) + + snapshot, err := fixture.service.GetUserEffectivePermissions(fixture.userID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions: %v", err) + } + + if !slices.Contains(snapshot.GroupMemberships, fixture.groupID) { + t.Fatalf("non-admin snapshot group memberships = %v, want to contain %d", + snapshot.GroupMemberships, fixture.groupID) + } + if !slices.Contains(snapshot.RoleAssignments[fixture.workspace], fixture.roleID) { + t.Fatalf("non-admin snapshot roles for workspace %d = %v, want to contain %d", + fixture.workspace, snapshot.RoleAssignments[fixture.workspace], fixture.roleID) + } +} diff --git a/internal/services/permission_cache_typed_test.go b/internal/services/permission_cache_typed_test.go new file mode 100644 index 0000000..9bf99d3 --- /dev/null +++ b/internal/services/permission_cache_typed_test.go @@ -0,0 +1,233 @@ +//go:build test + +package services + +import ( + "testing" + "time" + + "windshift/internal/cacheutil" + "windshift/internal/models" +) + +// The typed cache must hand back the exact stored pointer: a round-trip through +// JSON would allocate a copy and is the per-check cost WI-1906 removes. +func TestPermissionSnapshotCacheReturnsStoredPointerWithoutDecode(t *testing.T) { + service := &PermissionService{ + cache: cacheutil.NewTyped[*models.UserPermissionCache]("permission_pointer_test", cacheutil.TypedCacheOptions{ + TTL: time.Minute, + Shards: 4, + }), + ttl: time.Minute, + } + t.Cleanup(func() { _ = service.cache.Close() }) + + stored := &models.UserPermissionCache{ + UserID: 1, + IsSystemAdmin: true, + GlobalPermissions: map[string]bool{"system.admin": true}, + ExpiresAt: time.Now().Add(time.Minute), + } + if err := service.storeUserPermissionCache(1, stored); err != nil { + t.Fatalf("storeUserPermissionCache: %v", err) + } + + got, err := service.getUserPermissionCache(1) + if err != nil { + t.Fatalf("getUserPermissionCache: %v", err) + } + if got != stored { + t.Fatalf("cached snapshot = %p, want the stored pointer %p (a decode copied it)", got, stored) + } + + // A permission check served from the snapshot must not decode or + // re-serialize anything. + allowed, err := service.HasWorkspacePermission(1, 99, models.PermissionItemView) + if err != nil { + t.Fatalf("HasWorkspacePermission: %v", err) + } + if !allowed { + t.Fatal("system admin was denied item.view") + } + if decodes := service.GetCacheStats().PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission snapshot decodes = %d, want 0", decodes) + } +} + +func TestGetGroupMembershipsReturnsACopy(t *testing.T) { + service := &PermissionService{ + cache: cacheutil.NewTyped[*models.UserPermissionCache]("permission_group_copy_test", cacheutil.TypedCacheOptions{ + TTL: time.Minute, + Shards: 4, + }), + ttl: time.Minute, + } + t.Cleanup(func() { _ = service.cache.Close() }) + + if err := service.storeUserPermissionCache(1, &models.UserPermissionCache{ + UserID: 1, + GroupMemberships: []int{10, 20}, + ExpiresAt: time.Now().Add(time.Minute), + }); err != nil { + t.Fatalf("storeUserPermissionCache: %v", err) + } + + memberships, err := service.GetGroupMemberships(1) + if err != nil { + t.Fatalf("GetGroupMemberships: %v", err) + } + if len(memberships) != 2 || memberships[0] != 10 || memberships[1] != 20 { + t.Fatalf("memberships = %v, want [10 20]", memberships) + } + memberships[0] = 999 + + again, err := service.GetGroupMemberships(1) + if err != nil { + t.Fatalf("GetGroupMemberships second call: %v", err) + } + if again[0] != 10 { + t.Fatalf("mutating the returned slice changed the cached snapshot: %v", again) + } +} + +// insertAgent creates an owned agent row. Agents resolve their owner's +// permissions, which is the path that used to shallow-copy the owner snapshot. +func (e *permTestEnv) insertAgent(ownerID int, email string) int { + e.tb.Helper() + res, err := e.db.Exec( + `INSERT INTO users (email, username, first_name, last_name, is_agent, agent_owner_user_id) VALUES (?, ?, ?, '', true, ?)`, + email, email, email, ownerID, + ) + if err != nil { + e.tb.Fatalf("insert agent %s: %v", email, err) + } + id, _ := res.LastInsertId() + return int(id) +} + +// Regression: GetGroupMemberships used to hand out the snapshot's own slice, so +// a caller that mutated it (or appended into its spare capacity) corrupted the +// cached snapshot and every later read observed the caller's edit. This drives +// the real build path instead of seeding the cache by hand. +func TestGetGroupMembershipsBuildDoesNotLeakCallerMutations(t *testing.T) { + env := newPermTestEnv(t) + t.Cleanup(func() { _ = env.service.Close() }) + userID := env.insertUser("membership-copy@example.test") + groupID := env.insertGroup("membership-copy", true) + env.addGroupMember(groupID, userID) + + memberships, err := env.service.GetGroupMemberships(userID) + if err != nil { + t.Fatalf("GetGroupMemberships: %v", err) + } + if len(memberships) != 1 || memberships[0] != groupID { + t.Fatalf("memberships = %v, want [%d]", memberships, groupID) + } + + memberships[0] = 999 + memberships = append(memberships, 1000) + + again, err := env.service.GetGroupMemberships(userID) + if err != nil { + t.Fatalf("GetGroupMemberships second call: %v", err) + } + if len(again) != 1 || again[0] != groupID { + t.Fatalf("caller mutation leaked into the cached snapshot: %v", again) + } +} + +// Regression: the agent snapshot must not share maps with the owner's snapshot. +// The typed cache stores live pointers, so a shared map would let a mutation +// through one user's snapshot change another user's cached permissions. +func TestAgentSnapshotDoesNotAliasOwnerSnapshot(t *testing.T) { + env := newPermTestEnv(t) + t.Cleanup(func() { _ = env.service.Close() }) + ownerID := env.insertUser("agent-owner@example.test") + agentID := env.insertAgent(ownerID, "agent-owner-agent@example.test") + workspaceID := env.insertWorkspace("agent-workspace") + env.assignUserWorkspaceRole(ownerID, workspaceID, env.roleID("Viewer")) + + agentSnapshot, err := env.service.GetUserEffectivePermissions(agentID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions(agent): %v", err) + } + if agentSnapshot.UserID != agentID { + t.Fatalf("agent snapshot user = %d, want %d", agentSnapshot.UserID, agentID) + } + ownerSnapshot, err := env.service.GetUserEffectivePermissions(ownerID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions(owner): %v", err) + } + if agentSnapshot == ownerSnapshot { + t.Fatal("agent and owner share one snapshot pointer") + } + + // The agent inherits the owner's grant, but the maps must be independent in + // both directions. + agentSnapshot.WorkspacePermissions[workspaceID][models.PermissionItemView] = false + if !ownerSnapshot.WorkspacePermissions[workspaceID][models.PermissionItemView] { + t.Fatal("agent snapshot mutation leaked into the owner snapshot") + } + ownerSnapshot.GlobalPermissions["leaked"] = true + if agentSnapshot.GlobalPermissions["leaked"] { + t.Fatal("owner snapshot mutation leaked into the agent snapshot") + } +} + +func TestPermissionCacheMaxEntries(t *testing.T) { + if got := permissionCacheMaxEntries(PermissionCacheConfig{MaxEntries: 42, MaxCacheSize: 16}); got != 42 { + t.Fatalf("explicit MaxEntries = %d, want 42", got) + } + if got := permissionCacheMaxEntries(PermissionCacheConfig{MaxCacheSize: 16}); got != 1024 { + t.Fatalf("derived MaxEntries = %d, want 1024", got) + } + if got := permissionCacheMaxEntries(PermissionCacheConfig{}); got != 0 { + t.Fatalf("unconfigured MaxEntries = %d, want 0 (unbounded)", got) + } +} + +func TestCloneUserPermissionCacheDeepCopies(t *testing.T) { + original := &models.UserPermissionCache{ + UserID: 1, + IsSystemAdmin: true, + GlobalPermissions: map[string]bool{"global": true}, + WorkspacePermissions: map[int]map[string]bool{1: {"ws": true}}, + WorkspaceEveryone: map[int]map[string]bool{1: {"everyone": true}}, + GroupMemberships: []int{10, 20}, + RoleAssignments: map[int][]int{1: {5}}, + DirectPermissions: map[int][]string{1: {"direct"}}, + PermissionSources: map[int]map[string]string{1: {"ws": "role"}}, + } + + clone := cloneUserPermissionCache(original) + + clone.GlobalPermissions["global"] = false + clone.WorkspacePermissions[1]["ws"] = false + clone.WorkspaceEveryone[1]["everyone"] = false + clone.GroupMemberships[0] = 999 + clone.RoleAssignments[1][0] = 999 + clone.DirectPermissions[1][0] = "changed" + clone.PermissionSources[1]["ws"] = "changed" + + if !original.GlobalPermissions["global"] { + t.Fatal("global permissions aliased the clone") + } + if !original.WorkspacePermissions[1]["ws"] { + t.Fatal("workspace permissions aliased the clone") + } + if !original.WorkspaceEveryone[1]["everyone"] { + t.Fatal("workspace everyone aliased the clone") + } + if original.GroupMemberships[0] != 10 { + t.Fatal("group memberships aliased the clone") + } + if original.RoleAssignments[1][0] != 5 { + t.Fatal("role assignments aliased the clone") + } + if original.DirectPermissions[1][0] != "direct" { + t.Fatal("direct permissions aliased the clone") + } + if original.PermissionSources[1]["ws"] != "role" { + t.Fatal("permission sources aliased the clone") + } +} diff --git a/internal/services/permission_snapshot_compaction_test.go b/internal/services/permission_snapshot_compaction_test.go new file mode 100644 index 0000000..5fa2f61 --- /dev/null +++ b/internal/services/permission_snapshot_compaction_test.go @@ -0,0 +1,122 @@ +//go:build test + +package services + +import ( + "fmt" + "testing" + "time" + + "windshift/internal/models" + "windshift/internal/testutils" +) + +// A user snapshot must not embed an Everyone permission map per workspace: that +// made snapshot size scale with the total workspace count. The fallback is +// served by one shared, user-independent table instead. +func TestPermissionSnapshotDoesNotEmbedEveryonePerWorkspace(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + defer tdb.Close() + db := tdb.GetDatabase() + + config := DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permService, err := NewPermissionService(db, config) + if err != nil { + t.Fatalf("NewPermissionService: %v", err) + } + defer permService.Close() + + const workspaceCount = 40 + workspaceIDs := make([]int, 0, workspaceCount) + for i := range workspaceCount { + key := fmt.Sprintf("CMP%03d", i) + if _, err := db.Exec(` + INSERT INTO workspaces (name, key, description, active, is_personal, created_at, updated_at) + VALUES (?, ?, '', true, false, ?, ?) + `, fmt.Sprintf("Compaction %d", i), key, time.Now(), time.Now()); err != nil { + t.Fatalf("insert workspace %d: %v", i, err) + } + workspaceIDs = append(workspaceIDs, int(fixtureWorkspaceID(t, db, key))) + } + if _, err := db.Exec(` + INSERT INTO users (username, email, first_name, last_name, password_hash, created_at, updated_at) + VALUES ('compact', 'compact@test.com', 'Com', 'Pact', 'hash', ?, ?) + `, time.Now(), time.Now()); err != nil { + t.Fatalf("insert user: %v", err) + } + userID := int(fixtureUserID(t, db, "compact@test.com")) + + snapshot, err := permService.GetUserEffectivePermissions(userID) + if err != nil { + t.Fatalf("GetUserEffectivePermissions: %v", err) + } + if !snapshot.UsesSharedEveryone { + t.Fatal("snapshot did not opt into the shared Everyone table") + } + if len(snapshot.WorkspaceEveryone) != 0 { + t.Fatalf("snapshot embedded %d per-workspace Everyone entries, want 0", len(snapshot.WorkspaceEveryone)) + } + + // Open workspaces still resolve through the shared table. + for _, wsID := range []int{workspaceIDs[0], workspaceIDs[workspaceCount-1]} { + hasView, err := permService.HasWorkspacePermission(userID, wsID, models.PermissionItemView) + if err != nil { + t.Fatalf("HasWorkspacePermission(view) ws=%d: %v", wsID, err) + } + if !hasView { + t.Fatalf("open workspace %d did not grant item.view via shared Everyone table", wsID) + } + hasAdmin, err := permService.HasWorkspacePermission(userID, wsID, models.PermissionWorkspaceAdmin) + if err != nil { + t.Fatalf("HasWorkspacePermission(admin) ws=%d: %v", wsID, err) + } + if hasAdmin { + t.Fatalf("open workspace %d granted workspace.admin implicitly", wsID) + } + } + + // Locking one workspace down with an explicit Viewer assignment must remove + // the fallback for every other user after the global invalidation hook runs. + if _, err := db.Exec(` + INSERT INTO users (username, email, first_name, last_name, password_hash, created_at, updated_at) + VALUES ('locker', 'locker@test.com', 'Lock', 'Er', 'hash', ?, ?) + `, time.Now(), time.Now()); err != nil { + t.Fatalf("insert locker user: %v", err) + } + lockerID := int(fixtureUserID(t, db, "locker@test.com")) + if _, err := db.Exec(` + INSERT INTO user_workspace_roles (user_id, workspace_id, role_id, granted_by, granted_at) + VALUES (?, ?, (SELECT id FROM workspace_roles WHERE builtin_key = ?), ?, ?) + `, lockerID, workspaceIDs[0], models.RoleBuiltinViewer, lockerID, time.Now()); err != nil { + t.Fatalf("assign Viewer: %v", err) + } + permService.OnEveryoneAccessChanged() + + hasView, err := permService.HasWorkspacePermission(userID, workspaceIDs[0], models.PermissionItemView) + if err != nil { + t.Fatalf("HasWorkspacePermission after lock-down: %v", err) + } + if hasView { + t.Fatalf("locked-down workspace %d still granted item.view", workspaceIDs[0]) + } + hasView, err = permService.HasWorkspacePermission(userID, workspaceIDs[1], models.PermissionItemView) + if err != nil { + t.Fatalf("HasWorkspacePermission on open workspace: %v", err) + } + if !hasView { + t.Fatalf("open workspace %d lost item.view after unrelated lock-down", workspaceIDs[1]) + } + + // The materialized profile view reflects the same state. + everyone, err := permService.EveryonePermissions() + if err != nil { + t.Fatalf("EveryonePermissions: %v", err) + } + if _, ok := everyone[workspaceIDs[0]]; ok { + t.Fatalf("materialized Everyone fallback included locked-down workspace %d", workspaceIDs[0]) + } + if _, ok := everyone[workspaceIDs[1]]; !ok { + t.Fatalf("materialized Everyone fallback missing open workspace %d", workspaceIDs[1]) + } +} diff --git a/internal/services/permission_workspace_access_test.go b/internal/services/permission_workspace_access_test.go index dc0fbc8..cab2dc0 100644 --- a/internal/services/permission_workspace_access_test.go +++ b/internal/services/permission_workspace_access_test.go @@ -1,7 +1,6 @@ package services import ( - "context" "database/sql" "errors" "fmt" @@ -12,8 +11,6 @@ import ( "testing" "time" - "github.com/allegro/bigcache/v3" - "windshift/internal/cacheutil" "windshift/internal/database" "windshift/internal/models" @@ -483,7 +480,7 @@ func (db *countingPermissionDatabase) QueryRow(query string, args ...interface{} return db.Database.QueryRow(query, args...) } -func TestAccessibleWorkspaceIDsUsesOneDecodeAndNoWarmDatabaseQueries(t *testing.T) { +func TestAccessibleWorkspaceIDsReusesWarmSnapshotWithoutDecode(t *testing.T) { baseDB, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "permissions.db")) if err != nil { t.Fatalf("NewSQLiteDB: %v", err) @@ -540,7 +537,7 @@ func TestAccessibleWorkspaceIDsUsesOneDecodeAndNoWarmDatabaseQueries(t *testing. } countingDB.calls.Store(0) - before := service.GetWorkspaceAccessStats() + before := service.GetCacheStats() ids, err := service.AccessibleWorkspaceIDs(userID) if err != nil { t.Fatalf("first AccessibleWorkspaceIDs: %v", err) @@ -551,9 +548,12 @@ func TestAccessibleWorkspaceIDsUsesOneDecodeAndNoWarmDatabaseQueries(t *testing. if calls := countingDB.calls.Load(); calls != 1 { t.Fatalf("cold active-workspace DB calls = %d, want 1", calls) } - afterCold := service.GetWorkspaceAccessStats() - if afterCold.PermissionSnapshotDecodes-before.PermissionSnapshotDecodes != 1 { - t.Fatalf("cold permission decodes delta = %d, want 1", afterCold.PermissionSnapshotDecodes-before.PermissionSnapshotDecodes) + afterCold := service.GetCacheStats() + if afterCold.PermissionSnapshotDecodes != before.PermissionSnapshotDecodes { + t.Fatalf("cold permission decodes changed from %d to %d, want none", before.PermissionSnapshotDecodes, afterCold.PermissionSnapshotDecodes) + } + if hits := afterCold.Hits - before.Hits; hits != 1 { + t.Fatalf("cold permission cache hits = %d, want 1", hits) } countingDB.calls.Store(0) @@ -564,21 +564,31 @@ func TestAccessibleWorkspaceIDsUsesOneDecodeAndNoWarmDatabaseQueries(t *testing. if calls := countingDB.calls.Load(); calls != 0 { t.Fatalf("warm accessible-workspace DB calls = %d, want 0", calls) } - afterWarm := service.GetWorkspaceAccessStats() - if afterWarm.PermissionSnapshotDecodes-afterCold.PermissionSnapshotDecodes != 1 { - t.Fatalf("warm permission decodes delta = %d, want 1", afterWarm.PermissionSnapshotDecodes-afterCold.PermissionSnapshotDecodes) + afterWarm := service.GetCacheStats() + if afterWarm.PermissionSnapshotDecodes != afterCold.PermissionSnapshotDecodes { + t.Fatalf("warm permission decodes changed from %d to %d, want none", afterCold.PermissionSnapshotDecodes, afterWarm.PermissionSnapshotDecodes) + } + if hits := afterWarm.Hits - afterCold.Hits; hits != 1 { + t.Fatalf("warm permission cache hits = %d, want 1", hits) } if afterWarm.ActiveWorkspaceCacheHits == 0 { t.Fatal("warm active-workspace cache hit was not recorded") } newID := insertWorkspace("New", "NEW", true) - snapshot, err := service.getUserPermissionCache(userID) - if err != nil { - t.Fatalf("get permission cache: %v", err) - } - snapshot.WorkspaceEveryone[newID] = map[string]bool{models.PermissionItemView: true} - if err := service.storeUserPermissionCache(userID, snapshot); err != nil { + // Store a fresh snapshot: cached snapshots are shared pointers and must not + // be mutated in place. + if err := service.storeUserPermissionCache(userID, &models.UserPermissionCache{ + UserID: userID, + WorkspaceEveryone: map[int]map[string]bool{ + openID: {models.PermissionItemView: true}, + inactiveID: {models.PermissionItemView: true}, + newID: {models.PermissionItemView: true}, + }, + WorkspacePermissions: map[int]map[string]bool{}, + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Minute), + }); err != nil { t.Fatalf("update permission cache: %v", err) } service.InvalidateActiveWorkspaceCache() @@ -597,18 +607,10 @@ func TestAccessibleWorkspaceIDsUsesOneDecodeAndNoWarmDatabaseQueries(t *testing. func newSyntheticWorkspaceAccessService(tb testing.TB, workspaceCount int) *PermissionService { tb.Helper() - cacheConfig := cacheutil.NewBigCacheConfig(cacheutil.BigCacheOptions{ - TTL: time.Hour, - MaxCacheMB: 32, - Shards: 16, - MaxEntrySize: 1024 * 1024, - MaxEntriesInWin: 100, - CleanWindow: time.Minute, + cache := cacheutil.NewTyped[*models.UserPermissionCache]("permission_workspace_access_test", cacheutil.TypedCacheOptions{ + TTL: time.Hour, + Shards: 16, }) - cache, err := bigcache.New(context.Background(), cacheConfig) - if err != nil { - tb.Fatalf("bigcache.New: %v", err) - } tb.Cleanup(func() { _ = cache.Close() }) service := &PermissionService{ @@ -654,7 +656,7 @@ func TestAccessibleWorkspaceIDsDecodeCountDoesNotScale(t *testing.T) { service := newSyntheticWorkspaceAccessService(t, workspaceCount) service.db = countingDB countingDB.calls.Store(0) - before := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before := service.GetCacheStats() ids, err := service.AccessibleWorkspaceIDs(1) if err != nil { t.Fatalf("AccessibleWorkspaceIDs: %v", err) @@ -662,9 +664,12 @@ func TestAccessibleWorkspaceIDsDecodeCountDoesNotScale(t *testing.T) { if len(ids) != workspaceCount { t.Fatalf("accessible workspace count = %d, want %d", len(ids), workspaceCount) } - decodes := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before - if decodes != 1 { - t.Fatalf("permission snapshot decodes = %d, want 1", decodes) + after := service.GetCacheStats() + if decodes := after.PermissionSnapshotDecodes - before.PermissionSnapshotDecodes; decodes != 0 { + t.Fatalf("permission snapshot decodes = %d, want 0", decodes) + } + if hits := after.Hits - before.Hits; hits != 1 { + t.Fatalf("permission cache hits = %d, want 1", hits) } if calls := countingDB.calls.Load(); calls != 0 { t.Fatalf("warm permission-store queries = %d, want 0", calls) @@ -677,7 +682,7 @@ func BenchmarkAccessibleWorkspaceIDs(b *testing.B) { for _, workspaceCount := range []int{10, 100, 1000} { b.Run(fmt.Sprintf("legacy_%d_workspaces_parallel", workspaceCount), func(b *testing.B) { service := newSyntheticWorkspaceAccessService(b, workspaceCount) - before := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before := service.GetCacheStats().Hits b.ReportAllocs() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { @@ -691,13 +696,13 @@ func BenchmarkAccessibleWorkspaceIDs(b *testing.B) { } }) b.StopTimer() - decodes := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before - b.ReportMetric(float64(decodes)/float64(b.N), "permission-decodes/op") + hits := service.GetCacheStats().Hits - before + b.ReportMetric(float64(hits)/float64(b.N), "permission-cache-hits/op") }) b.Run(fmt.Sprintf("snapshot_%d_workspaces_parallel", workspaceCount), func(b *testing.B) { service := newSyntheticWorkspaceAccessService(b, workspaceCount) - before := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes + before := service.GetCacheStats().Hits b.ReportAllocs() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { @@ -709,8 +714,8 @@ func BenchmarkAccessibleWorkspaceIDs(b *testing.B) { } }) b.StopTimer() - decodes := service.GetWorkspaceAccessStats().PermissionSnapshotDecodes - before - b.ReportMetric(float64(decodes)/float64(b.N), "permission-decodes/op") + hits := service.GetCacheStats().Hits - before + b.ReportMetric(float64(hits)/float64(b.N), "permission-cache-hits/op") }) } } diff --git a/internal/services/script_engine_test.go b/internal/services/script_engine_test.go index e40ff4c..9bbd2d1 100644 --- a/internal/services/script_engine_test.go +++ b/internal/services/script_engine_test.go @@ -1,3 +1,5 @@ +//go:build test + package services import ( @@ -191,6 +193,70 @@ func TestScriptEngine_GlobalThisCleanup(t *testing.T) { } } +func TestScriptEngine_DoesNotLeakReplacedBuiltins(t *testing.T) { + // A condition script may reassign a global. The next execution must still + // observe the original built-in. + engine := NewScriptEngine() + ctx := context.Background() + + cases := []struct { + name string + tamper string + probe string + }{ + { + name: "JSON.stringify", + tamper: `JSON.stringify = () => 'tampered'; true`, + probe: `JSON.stringify({a:1}) === '{"a":1}'`, + }, + { + name: "Object.keys", + tamper: `Object.keys = () => []; true`, + probe: `Object.keys({a:1}).length === 1`, + }, + { + name: "Array.isArray", + tamper: `Array.isArray = () => false; true`, + probe: `Array.isArray([]) === true`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := engine.Execute(ctx, c.tamper, nil, 0); err != nil { + t.Fatalf("tamper script failed: %v", err) + } + + got, err := engine.ExecuteBool(ctx, c.probe, nil, 0) + if err != nil { + t.Fatalf("probe script failed: %v", err) + } + if !got { + t.Fatalf("replaced %s survived into the next execution", c.name) + } + }) + } +} + +func TestScriptEngine_DoesNotLeakPrototypePollution(t *testing.T) { + // Properties added to a built-in prototype are not own keys of the global + // object, so cleanup-by-key can never remove them. A fresh realm must. + engine := NewScriptEngine() + ctx := context.Background() + + if _, err := engine.Execute(ctx, `Object.prototype.polluted = 1; true`, nil, 0); err != nil { + t.Fatalf("pollution script failed: %v", err) + } + + got, err := engine.ExecuteBool(ctx, `({}).polluted === undefined`, nil, 0) + if err != nil { + t.Fatalf("probe script failed: %v", err) + } + if !got { + t.Fatal("Object.prototype.polluted survived into the next execution") + } +} + func TestScriptEngine_ConcurrentExecuteBool(t *testing.T) { // With -race this exercises the pool path, the interrupt goroutine join, // and ensures no sobek.Value escapes the runtime (Export must happen while diff --git a/internal/services/sla_side_effects_test.go b/internal/services/sla_side_effects_test.go index bceacd4..be86418 100644 --- a/internal/services/sla_side_effects_test.go +++ b/internal/services/sla_side_effects_test.go @@ -98,16 +98,16 @@ func TestSLASideEffectEmitterEmitsConfiguredNotification(t *testing.T) { fixture.emitBreach(t, fixture.cycle(deadline)) - if got := countDomainEvents(t, fixture.db, DurableNotificationCompatibilityEvent); got != 1 { + if got := countDomainEvents(t, fixture.db, DurableNotificationRequestedEvent); got != 1 { t.Fatalf("notification compatibility events = %d, want 1", got) } - if got := countDomainEvents(t, fixture.db, DurableActionCompatibilityEvent); got != 0 { + if got := countDomainEvents(t, fixture.db, DurableActionRequestedEvent); got != 0 { t.Fatalf("action compatibility events = %d, want 0 without a rule", got) } var payload []byte var occurredAt time.Time - if err := fixture.db.QueryRow(`SELECT payload, occurred_at FROM domain_events WHERE event_type = ?`, DurableNotificationCompatibilityEvent).Scan(&payload, &occurredAt); err != nil { + if err := fixture.db.QueryRow(`SELECT payload, occurred_at FROM domain_events WHERE event_type = ?`, DurableNotificationRequestedEvent).Scan(&payload, &occurredAt); err != nil { t.Fatalf("load notification event: %v", err) } var event NotificationEvent @@ -129,10 +129,10 @@ func TestSLASideEffectEmitterGatedWithoutConfiguration(t *testing.T) { fixture := newSLASideEffectFixture(t) fixture.emitBreach(t, fixture.cycle(time.Now().UTC())) - if got := countDomainEvents(t, fixture.db, DurableNotificationCompatibilityEvent); got != 0 { + if got := countDomainEvents(t, fixture.db, DurableNotificationRequestedEvent); got != 0 { t.Fatalf("notification events = %d, want 0", got) } - if got := countDomainEvents(t, fixture.db, DurableActionCompatibilityEvent); got != 0 { + if got := countDomainEvents(t, fixture.db, DurableActionRequestedEvent); got != 0 { t.Fatalf("action events = %d, want 0", got) } } @@ -143,11 +143,11 @@ func TestSLASideEffectEmitterEmitsConfiguredAction(t *testing.T) { fixture.emitBreach(t, fixture.cycle(time.Now().UTC())) - if got := countDomainEvents(t, fixture.db, DurableActionCompatibilityEvent); got != 1 { + if got := countDomainEvents(t, fixture.db, DurableActionRequestedEvent); got != 1 { t.Fatalf("action compatibility events = %d, want 1", got) } var payload []byte - if err := fixture.db.QueryRow(`SELECT payload FROM domain_events WHERE event_type = ?`, DurableActionCompatibilityEvent).Scan(&payload); err != nil { + if err := fixture.db.QueryRow(`SELECT payload FROM domain_events WHERE event_type = ?`, DurableActionRequestedEvent).Scan(&payload); err != nil { t.Fatalf("load action event: %v", err) } var event models.ActionEvent @@ -172,11 +172,11 @@ func TestSLASideEffectEmitterWarningUsesWarningTriggers(t *testing.T) { }); err != nil { t.Fatalf("EmitWarning: %v", err) } - if got := countDomainEvents(t, fixture.db, DurableNotificationCompatibilityEvent); got != 1 { + if got := countDomainEvents(t, fixture.db, DurableNotificationRequestedEvent); got != 1 { t.Fatalf("warning notification events = %d, want 1", got) } var payload []byte - if err := fixture.db.QueryRow(`SELECT payload FROM domain_events WHERE event_type = ?`, DurableActionCompatibilityEvent).Scan(&payload); err != nil { + if err := fixture.db.QueryRow(`SELECT payload FROM domain_events WHERE event_type = ?`, DurableActionRequestedEvent).Scan(&payload); err != nil { t.Fatalf("load warning action event: %v", err) } var event models.ActionEvent @@ -190,3 +190,27 @@ func TestSLASideEffectEmitterWarningUsesWarningTriggers(t *testing.T) { t.Fatalf("threshold_key = %v, want 75pct", event.NewValues["threshold_key"]) } } + +// TestSLASideEffectEmitterPreservesImportOrigin asserts an imported SLA cycle +// stamps import provenance on its side-effect events so the durable consumers +// can refuse to turn the import into live output. +func TestSLASideEffectEmitterPreservesImportOrigin(t *testing.T) { + fixture := newSLASideEffectFixture(t) + fixture.enableNotification(t, models.EventSLABreached) + fixture.enableAction(t, string(models.ActionTriggerSLABreached)) + deadline := time.Date(2025, 1, 8, 13, 0, 0, 0, time.UTC) + cycle := fixture.cycle(deadline) + cycle.Origin = models.SLAOriginImport + + fixture.emitBreach(t, cycle) + + for _, eventType := range []string{DurableNotificationRequestedEvent, DurableActionRequestedEvent} { + var actorKind, sourceKind string + if err := fixture.db.QueryRow(`SELECT actor_kind, source_kind FROM domain_events WHERE event_type = ?`, eventType).Scan(&actorKind, &sourceKind); err != nil { + t.Fatalf("load %s provenance: %v", eventType, err) + } + if actorKind != "import" || sourceKind != "import" { + t.Fatalf("%s provenance = %q/%q, want import/import", eventType, actorKind, sourceKind) + } + } +} diff --git a/internal/services/sse_hub_test.go b/internal/services/sse_hub_test.go index c5acaf4..5ab5480 100644 --- a/internal/services/sse_hub_test.go +++ b/internal/services/sse_hub_test.go @@ -221,3 +221,15 @@ func TestSSEHubDefaultUserStreamCap(t *testing.T) { t.Fatalf("acquired beyond the default cap of %d", DefaultMaxUserStreams) } } + +func TestSSEHubDefaultUserStreamCapAccommodatesSeveralTabs(t *testing.T) { + // One tab holds up to three topic streams (notifications, workspace or + // collection, item detail) and reconnects briefly overlap. The default must + // leave room for several tabs; the previous default of 8 tripped after two + // tabs plus an overlap, surfacing a spurious 429 on item detail. + const streamsPerTab = 3 + const tabs = 10 + if DefaultMaxUserStreams < streamsPerTab*tabs { + t.Fatalf("DefaultMaxUserStreams = %d, want at least %d for %d tabs", DefaultMaxUserStreams, streamsPerTab*tabs, tabs) + } +} diff --git a/internal/services/status_loop_trigger_test.go b/internal/services/status_loop_trigger_test.go new file mode 100644 index 0000000..e36da5a --- /dev/null +++ b/internal/services/status_loop_trigger_test.go @@ -0,0 +1,284 @@ +//go:build test + +package services + +import ( + "context" + "encoding/json" + "strconv" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/events" + "windshift/internal/itemevents" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +type statusLoopTestEnv struct { + db database.Database + workspaceID int + statusID int + itemTypeID int + actorUserID int + itemID int +} + +// newStatusLoopTestEnv builds a workspace whose workflow has one status. When +// declareSelfTransition is set the workflow also declares the self-transition +// that makes a same-status update a status loop. +func newStatusLoopTestEnv(t *testing.T, declareSelfTransition bool) statusLoopTestEnv { + t.Helper() + + tdb := testutils.CreateTestDB(t, true) + if tdb.Engine == "sqlite" { + t.Cleanup(func() { _ = tdb.Close() }) + } + db := tdb.DB + + insertID := func(query string, args ...any) int { + t.Helper() + return testutils.InsertID(t, db, query, args...) + } + + workspaceID := insertID(`INSERT INTO workspaces (name, key, active, is_personal) VALUES ('Loop', 'LOOP', true, false)`) + actorUserID := insertID(` + INSERT INTO users (email, username, first_name, last_name) + VALUES ('loop@example.com', 'loop', 'Loop', 'User') + `) + var categoryID int + if err := db.QueryRow(`SELECT id FROM status_categories LIMIT 1`).Scan(&categoryID); err != nil { + t.Fatalf("load status category: %v", err) + } + statusID := insertID(`INSERT INTO statuses (name, category_id) VALUES ('Loop review', ?)`, categoryID) + workflowID := insertID(`INSERT INTO workflows (name, description, is_default) VALUES ('loop', '', false)`) + insertID(`INSERT INTO workflow_transitions (workflow_id, from_status_id, to_status_id) VALUES (?, NULL, ?)`, workflowID, statusID) + if declareSelfTransition { + insertID(`INSERT INTO workflow_transitions (workflow_id, from_status_id, to_status_id) VALUES (?, ?, ?)`, workflowID, statusID, statusID) + } + configurationSetID := insertID(`INSERT INTO configuration_sets (name, workflow_id) VALUES ('loop', ?)`, workflowID) + insertID(`INSERT INTO workspace_configuration_sets (workspace_id, configuration_set_id) VALUES (?, ?)`, workspaceID, configurationSetID) + var defaultItemTypeID int + if err := db.QueryRow(`SELECT id FROM item_types WHERE is_default = true LIMIT 1`).Scan(&defaultItemTypeID); err != nil { + t.Fatalf("load default item type: %v", err) + } + insertID(`INSERT INTO configuration_set_item_types (configuration_set_id, item_type_id) VALUES (?, ?)`, configurationSetID, defaultItemTypeID) + + itemID64, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: workspaceID, + ItemTypeID: &defaultItemTypeID, + Title: "Loop item", + StatusID: &statusID, + CreatorID: &actorUserID, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + + return statusLoopTestEnv{ + db: db, + workspaceID: workspaceID, + statusID: statusID, + itemTypeID: defaultItemTypeID, + actorUserID: actorUserID, + itemID: int(itemID64), + } +} + +func (env statusLoopTestEnv) transitionToSelf(t *testing.T) *PerformTransitionResult { + t.Helper() + result, err := NewWorkflowService(env.db).PerformTransition(context.Background(), PerformTransitionRequest{ + ItemID: env.itemID, + ToStatusID: env.statusID, + ActorUserID: env.actorUserID, + }, repository.NewItemRepository(env.db), nil, nil) + if err != nil { + t.Fatalf("PerformTransition error = %v", err) + } + return result +} + +func TestPerformTransitionRecordsDeclaredStatusLoop(t *testing.T) { + env := newStatusLoopTestEnv(t, true) + + result := env.transitionToSelf(t) + if !result.NoOp || !result.Loop { + t.Fatalf("result NoOp=%v Loop=%v, want true/true", result.NoOp, result.Loop) + } + if result.OldStatusID == nil || *result.OldStatusID != env.statusID || + result.NewStatusID == nil || *result.NewStatusID != env.statusID { + t.Fatalf("status ids = %v/%v, want %d/%d", result.OldStatusID, result.NewStatusID, env.statusID, env.statusID) + } + + var historyCount int + if err := env.db.QueryRow(` + SELECT COUNT(*) FROM item_history + WHERE item_id = ? AND field_name = 'status_id' AND old_value = ? AND new_value = ? + `, env.itemID, strconv.Itoa(env.statusID), strconv.Itoa(env.statusID)).Scan(&historyCount); err != nil { + t.Fatalf("count loop history: %v", err) + } + if historyCount != 1 { + t.Fatalf("loop history rows = %d, want 1", historyCount) + } + + var loopEvents int + if err := env.db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE event_type = ?`, itemevents.StatusLooped).Scan(&loopEvents); err != nil { + t.Fatalf("count loop events: %v", err) + } + if loopEvents != 1 { + t.Fatalf("status_looped events = %d, want 1", loopEvents) + } +} + +func TestPerformTransitionIgnoresUndeclaredStatusLoop(t *testing.T) { + env := newStatusLoopTestEnv(t, false) + + result := env.transitionToSelf(t) + if !result.NoOp || result.Loop { + t.Fatalf("result NoOp=%v Loop=%v, want true/false", result.NoOp, result.Loop) + } + + var loopHistory int + if err := env.db.QueryRow(` + SELECT COUNT(*) FROM item_history + WHERE item_id = ? AND field_name = 'status_id' AND old_value = new_value + `, env.itemID).Scan(&loopHistory); err != nil { + t.Fatalf("count loop history: %v", err) + } + if loopHistory != 0 { + t.Fatalf("loop history rows = %d, want 0", loopHistory) + } + + var loopEvents int + if err := env.db.QueryRow(`SELECT COUNT(*) FROM domain_events WHERE event_type = ?`, itemevents.StatusLooped).Scan(&loopEvents); err != nil { + t.Fatalf("count loop events: %v", err) + } + if loopEvents != 0 { + t.Fatalf("status_looped events = %d, want 0", loopEvents) + } +} + +func TestActionEventFromDomainEventMapsStatusLooped(t *testing.T) { + statusID := 42 + payload, err := json.Marshal(itemevents.StatusChangedV1{ + Item: itemevents.ItemSnapshot{ID: 7, WorkspaceID: 3, StatusID: &statusID}, + OldStatusID: &statusID, + NewStatusID: &statusID, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + event := events.Event{ + ID: 1, Key: "status-looped-1", Type: itemevents.StatusLooped, + PayloadVersion: itemevents.PayloadVersion, Payload: payload, + OccurredAt: time.Now(), ActorKind: "user", ActorRef: "9", + } + + actionEvent, consumerKey, err := actionEventFromDomainEvent(event) + if err != nil { + t.Fatalf("actionEventFromDomainEvent error = %v", err) + } + if consumerKey != DurableItemActionConsumerKey { + t.Fatalf("consumer key = %q, want %q", consumerKey, DurableItemActionConsumerKey) + } + if actionEvent.EventType != models.ActionTriggerStatusLoop { + t.Fatalf("event type = %q, want %q", actionEvent.EventType, models.ActionTriggerStatusLoop) + } + if actionEvent.OldValues["status_id"] != statusID || actionEvent.NewValues["status_id"] != statusID { + t.Fatalf("status values = %v/%v, want %d/%d", actionEvent.OldValues["status_id"], actionEvent.NewValues["status_id"], statusID, statusID) + } +} + +func TestMatchesStatusLoop(t *testing.T) { + statusID := 5 + itemTypeID := 9 + event := &models.ActionEvent{ + NewValues: map[string]any{"status_id": statusID, "item_type_id": itemTypeID}, + } + tests := []struct { + name string + config models.ActionTriggerConfig + want bool + }{ + {name: "no filter matches", config: models.ActionTriggerConfig{}, want: true}, + {name: "matching status", config: models.ActionTriggerConfig{ToStatusID: &statusID}, want: true}, + {name: "other status", config: models.ActionTriggerConfig{ToStatusID: loopIntPtr(6)}, want: false}, + {name: "matching item type", config: models.ActionTriggerConfig{ItemTypeID: &itemTypeID}, want: true}, + {name: "other item type", config: models.ActionTriggerConfig{ItemTypeID: loopIntPtr(10)}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchesStatusLoop(tt.config, event); got != tt.want { + t.Fatalf("matchesStatusLoop() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestStatusLoopTriggerFiresAction proves the durable pipeline maps the loop +// fact to the status_loop trigger and leaves status_transition alone. +func TestStatusLoopTriggerFiresAction(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + data := tdb.SeedTestData(t) + db := tdb.GetDatabase() + + statusID := data.StatusID + + repo := repository.NewActionRepository(db) + loopActionID := createDurableTestAction(t, repo, data.WorkspaceID, "loop", models.ActionTriggerStatusLoop) + transitionActionID := createDurableTestAction(t, repo, data.WorkspaceID, "transition", models.ActionTriggerStatusTransition) + + itemID64, err := CreateItem(db, ItemCreationParams{ + WorkspaceID: data.WorkspaceID, + Title: "loop trigger item", + StatusID: &statusID, + CreatorID: &data.UserID, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + itemID := int(itemID64) + + payload, err := json.Marshal(itemevents.StatusChangedV1{ + Item: itemevents.ItemSnapshot{ID: itemID, WorkspaceID: data.WorkspaceID, StatusID: &statusID}, + OldStatusID: &statusID, + NewStatusID: &statusID, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + event := events.Event{ + ID: 91, Key: "item-status-looped-91", WorkspaceID: &data.WorkspaceID, + AggregateType: "item", AggregateID: strconv.Itoa(itemID), AggregateSequence: 1, + Type: itemevents.StatusLooped, PayloadVersion: itemevents.PayloadVersion, + OccurredAt: time.Now(), ActorKind: "user", ActorRef: strconv.Itoa(data.UserID), SourceKind: "test", Payload: payload, + } + + service := NewActionService(db, DefaultActionServiceConfig(), nil) + t.Cleanup(service.Stop) + service.InvalidateWorkspaceCache(data.WorkspaceID) + consumer := NewDurableActionConsumer(db, service) + + if err := consumer.Handle(context.Background(), event); err != nil { + t.Fatalf("Handle() error = %v", err) + } + + var loopExecutions, transitionExecutions int + if err := db.QueryRow(`SELECT COUNT(*) FROM action_execution_logs WHERE action_id = ?`, loopActionID).Scan(&loopExecutions); err != nil { + t.Fatalf("count loop executions: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM action_execution_logs WHERE action_id = ?`, transitionActionID).Scan(&transitionExecutions); err != nil { + t.Fatalf("count transition executions: %v", err) + } + if loopExecutions != 1 { + t.Fatalf("loop action executions = %d, want 1", loopExecutions) + } + if transitionExecutions != 0 { + t.Fatalf("status_transition action executions = %d, want 0", transitionExecutions) + } +} + +func loopIntPtr(value int) *int { return &value } diff --git a/internal/services/time_worklog_aggregate_test.go b/internal/services/time_worklog_aggregate_test.go index a18c532..1fc6745 100644 --- a/internal/services/time_worklog_aggregate_test.go +++ b/internal/services/time_worklog_aggregate_test.go @@ -38,12 +38,12 @@ func TestSplitWorklogMinutesByDay(t *testing.T) { } cases := []struct { - name string - start int64 - end int64 - location *time.Location - wantDays []string - wantSum int64 + name string + start int64 + end int64 + location *time.Location + wantDays []string + wantSum int64 }{ { name: "intraDaySingleSlice", @@ -79,7 +79,7 @@ func TestSplitWorklogMinutesByDay(t *testing.T) { }, { name: "dstSpringForwardKeepsElapsedMinutes", - start: time.Date(2026, 3, 8, 6, 0, 0, 0, time.UTC).Unix(), // 01:00 EST + start: time.Date(2026, 3, 8, 6, 0, 0, 0, time.UTC).Unix(), // 01:00 EST end: time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC).Unix(), // 03:30 EDT location: newYork, wantDays: []string{"2026-03-08"}, @@ -141,8 +141,8 @@ func TestTimeWorklogService_Aggregate(t *testing.T) { insertWorklog := func(start, end int64, minutes, userID, projectID int) { t.Helper() if _, err := tdb.Exec(` - INSERT INTO time_worklogs (project_id, customer_id, user_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) - VALUES (?, 700, ?, 'desc', ?, ?, ?, ?, 0, 0) + INSERT INTO time_worklogs (project_id, user_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) + VALUES (?, ?, 'desc', ?, ?, ?, ?, 0, 0) `, projectID, userID, start, start, end, minutes); err != nil { t.Fatalf("seed worklog: %v", err) } @@ -240,8 +240,8 @@ func TestTimeWorklogService_AggregateCancellationStopsTheScan(t *testing.T) { start := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { if _, err := tdb.Exec(` - INSERT INTO time_worklogs (project_id, customer_id, user_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) - VALUES (800, 700, ?, 'desc', ?, ?, ?, 60, 0, 0) + INSERT INTO time_worklogs (project_id, user_id, description, date, start_time, end_time, duration_minutes, created_at, updated_at) + VALUES (800, ?, 'desc', ?, ?, ?, 60, 0, 0) `, data.UserID, start.Unix(), start.Unix(), start.Add(time.Hour).Unix()); err != nil { t.Fatalf("seed worklog: %v", err) } diff --git a/internal/services/timer_service_core_regression_test.go b/internal/services/timer_service_core_regression_test.go index 69ff195..e809efd 100644 --- a/internal/services/timer_service_core_regression_test.go +++ b/internal/services/timer_service_core_regression_test.go @@ -35,7 +35,7 @@ func TestGetActiveForUserRedactsMetadataAfterWorkspaceRevocation(t *testing.T) { } } -func TestStopTimerWithoutProjectCustomerCancelsInsteadOfWedging(t *testing.T) { +func TestStopTimerWithoutRealCustomerCreatesWorklog(t *testing.T) { fixture := newTimerServiceFixture(t, false, false) fixture.service.workspaceAccess = func(int, int) (bool, error) { return true, nil } @@ -43,8 +43,8 @@ func TestStopTimerWithoutProjectCustomerCancelsInsteadOfWedging(t *testing.T) { if err != nil { t.Fatalf("StopTimerByID: %v", err) } - if result.WorklogCreated { - t.Fatal("customerless timer unexpectedly created a worklog") + if !result.WorklogCreated { + t.Fatal("timer on a no-customer project did not create a worklog") } var timerCount, worklogCount int @@ -54,8 +54,23 @@ func TestStopTimerWithoutProjectCustomerCancelsInsteadOfWedging(t *testing.T) { if err := fixture.db.QueryRow(`SELECT COUNT(*) FROM time_worklogs WHERE user_id = ?`, fixture.userID).Scan(&worklogCount); err != nil { t.Fatalf("count worklogs: %v", err) } - if timerCount != 0 || worklogCount != 0 { - t.Fatalf("after recovery timer_count=%d worklog_count=%d, want 0/0", timerCount, worklogCount) + if timerCount != 0 || worklogCount != 1 { + t.Fatalf("after stop timer_count=%d worklog_count=%d, want 0/1", timerCount, worklogCount) + } + + // The worklog derives the built-in no-customer placeholder from the project. + var builtinKey string + if err := fixture.db.QueryRow(` + SELECT COALESCE(c.builtin_key, '') + FROM time_worklogs w + JOIN time_projects p ON w.project_id = p.id + JOIN customer_organisations c ON p.customer_id = c.id + WHERE w.user_id = ? + `, fixture.userID).Scan(&builtinKey); err != nil { + t.Fatalf("read derived customer: %v", err) + } + if builtinKey != "no_customer" { + t.Fatalf("worklog customer builtin_key = %q, want no_customer", builtinKey) } } @@ -111,6 +126,12 @@ func newTimerServiceFixture(t *testing.T, withCustomer, withItem bool) timerServ var customerID interface{} if withCustomer { customerID = insert(`INSERT INTO customer_organisations (name) VALUES ('Timer customer')`) + } else { + var noCustomerID int + if err := db.QueryRow(`SELECT id FROM customer_organisations WHERE builtin_key = 'no_customer'`).Scan(&noCustomerID); err != nil { + t.Fatalf("load no-customer organisation: %v", err) + } + customerID = noCustomerID } projectID := insert(`INSERT INTO time_projects (customer_id, name, status) VALUES (?, 'Timer project', 'Active')`, customerID) diff --git a/internal/services/user_preferences_comment_sort_test.go b/internal/services/user_preferences_comment_sort_test.go new file mode 100644 index 0000000..0ccf7c6 --- /dev/null +++ b/internal/services/user_preferences_comment_sort_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "errors" + "testing" +) + +// TestCommentSortOrderPreferencesRoundTrip pins the per-user comment thread +// order: a fresh user defaults to oldest-first, a patch persists newest-first, +// and unrelated preferences survive the write. +func TestCommentSortOrderPreferencesRoundTrip(t *testing.T) { + _, svc := openTUIPrefsTestDB(t) + + fresh, err := svc.GetSnapshot(1) + if err != nil { + t.Fatalf("fresh snapshot: %v", err) + } + if fresh.CommentSortOrder != CommentSortOldest { + t.Fatalf("fresh comment sort order = %q, want %q", fresh.CommentSortOrder, CommentSortOldest) + } + + updated, err := svc.UpdateSnapshot(1, UserPreferencesPatch{ + CommentSortOrderSet: true, + CommentSortOrder: CommentSortNewest, + }) + if err != nil { + t.Fatalf("update comment sort order: %v", err) + } + if updated.CommentSortOrder != CommentSortNewest { + t.Fatalf("updated comment sort order = %q, want %q", updated.CommentSortOrder, CommentSortNewest) + } + + // A later patch that omits the field must not reset it. + unchanged, err := svc.UpdateSnapshot(1, UserPreferencesPatch{ + ViewStateSet: true, + ViewState: map[string]map[string]any{"collection-5": {"board": map[string]any{"sort_mode": "bubble"}}}, + }) + if err != nil { + t.Fatalf("unrelated update: %v", err) + } + if unchanged.CommentSortOrder != CommentSortNewest { + t.Fatalf("comment sort order after unrelated patch = %q, want %q", unchanged.CommentSortOrder, CommentSortNewest) + } + + reloaded, err := svc.GetSnapshot(1) + if err != nil { + t.Fatalf("reload snapshot: %v", err) + } + if reloaded.CommentSortOrder != CommentSortNewest { + t.Fatalf("reloaded comment sort order = %q, want %q", reloaded.CommentSortOrder, CommentSortNewest) + } +} + +func TestCommentSortOrderPreferencesRejectUnknownOrder(t *testing.T) { + _, svc := openTUIPrefsTestDB(t) + + for _, order := range []string{"", "OLDEST", "recent", "newest-first"} { + _, err := svc.UpdateSnapshot(1, UserPreferencesPatch{ + CommentSortOrderSet: true, + CommentSortOrder: order, + }) + if !errors.Is(err, ErrInvalidCommentSortOrder) { + t.Fatalf("order=%q error = %v, want ErrInvalidCommentSortOrder", order, err) + } + } +} diff --git a/internal/sla/engine_test.go b/internal/sla/engine_test.go index a404614..3264ac6 100644 --- a/internal/sla/engine_test.go +++ b/internal/sla/engine_test.go @@ -691,6 +691,51 @@ func TestBreachJobInvokesSideEffectEmitter(t *testing.T) { } } +// TestBreachJobForInactiveMetricRetiresWithoutSideEffects asserts a breach job +// that outlives a metric deactivation retires silently. Imported metrics are +// inactive by default, so this also covers an import that never armed a live +// SLA. +func TestBreachJobForInactiveMetricRetiresWithoutSideEffects(t *testing.T) { + fixture := newEngineFixture(t) + now := time.Date(2025, 1, 8, 12, 0, 0, 0, time.UTC) + clock := &advanceClock{now: now} + fixture.engine.SetClock(clock) + effects := &recordingSideEffects{} + fixture.engine.SetSideEffectEmitter(effects) + fixture.createMetric(t, + []models.SLACondition{statusCondition(models.SLAPhaseStart, fixture.statusOpen)}, + fixture.defaultGoal(), + ) + fixture.observe(t, fixture.createdFact()) + + var jobID, cycleID int64 + var deadline time.Time + if err := fixture.db.QueryRow(`SELECT id, cycle_id, deadline_at FROM sla_jobs WHERE kind = 'breach'`).Scan(&jobID, &cycleID, &deadline); err != nil { + t.Fatalf("load breach job: %v", err) + } + + // Deactivate the metric after the job was armed. A stale job must retire + // silently instead of emitting a breach for a metric the workspace stopped + // running. + if _, err := fixture.db.Exec(`UPDATE sla_metrics SET is_active = false`); err != nil { + t.Fatalf("deactivate metric: %v", err) + } + fixture.engine.InvalidateWorkspace(fixture.workspaceID) + + clock.now = deadline.Add(time.Second) + if err := fixture.engine.RunJob(context.Background(), models.SLAJob{ + ID: jobID, Kind: models.SLAJobBreach, CycleID: &cycleID, DueAt: deadline, DeadlineAt: &deadline, + }); err != nil { + t.Fatalf("RunJob: %v", err) + } + if effects.breaches != 0 { + t.Fatalf("inactive metric emitted %d breach side effects, want 0", effects.breaches) + } + if got := fixture.breachJobCount(t); got != 0 { + t.Fatalf("stale breach job count = %d, want 0 after retirement", got) + } +} + func TestCalendarRecalculationUpdatesExistingCyclesWithoutStartingCycles(t *testing.T) { fixture := newEngineFixture(t) now := time.Date(2025, 1, 8, 16, 0, 0, 0, time.UTC) @@ -954,7 +999,7 @@ func TestCommentByAgentStopsCycle(t *testing.T) { t.Fatalf("cycle status = %s, want completed after a public agent comment", status) } if elapsed != (5 * time.Minute).Milliseconds() { - t.Fatalf("elapsed = %d, want %d", elapsed, (5*time.Minute).Milliseconds()) + t.Fatalf("elapsed = %d, want %d", elapsed, (5 * time.Minute).Milliseconds()) } if !stoppedAt.Equal(clock.now) { t.Fatalf("stopped_at = %s, want %s", stoppedAt, clock.now) diff --git a/internal/sla/guardrails_test.go b/internal/sla/guardrails_test.go index 8021358..7a2a04b 100644 --- a/internal/sla/guardrails_test.go +++ b/internal/sla/guardrails_test.go @@ -121,3 +121,29 @@ func TestIrrelevantChangeIssuesAtMostOneQuery(t *testing.T) { t.Fatalf("irrelevant-change path issued %d read queries, want exactly the workspace gate (1)", got) } } + +// TestObserveItemFactsSkipsImportSourceKind asserts the import guard honors +// SourceKind alone. Some import paths (CommentService.CreateImported) stamp +// only that field, and evaluating those facts would double-count imported +// cycles. +func TestObserveItemFactsSkipsImportSourceKind(t *testing.T) { + fixture := newEngineFixture(t) + fixture.createMetric(t, + []models.SLACondition{ + statusCondition(models.SLAPhaseStart, fixture.statusOpen), + statusCondition(models.SLAPhaseStop, fixture.statusDone), + }, + fixture.defaultGoal(), + ) + + fact := fixture.createdFact() + fact.Metadata = itemevents.Metadata{ActorKind: "user", SourceKind: "import"} + fixture.observe(t, fact) + + if got := fixture.cycleCount(t); got != 0 { + t.Fatalf("import-origin fact created %d SLA cycles, want 0", got) + } + if got := fixture.breachJobCount(t); got != 0 { + t.Fatalf("import-origin fact armed %d breach jobs, want 0", got) + } +} diff --git a/internal/smtp/digest_localized_copy_test.go b/internal/smtp/digest_localized_copy_test.go new file mode 100644 index 0000000..c83e0a0 --- /dev/null +++ b/internal/smtp/digest_localized_copy_test.go @@ -0,0 +1,103 @@ +//go:build test + +package smtp + +import ( + "strings" + "testing" + "time" + + "windshift/internal/emailutil" + "windshift/internal/models" +) + +// TestDigestRendersLocalizedNotificationCopy covers the digest re-rendering a +// notification's stored English title/message in the recipient's locale when +// the row carries i18n metadata. +func TestDigestRendersLocalizedNotificationCopy(t *testing.T) { + sender := &NotificationSMTPSender{} + notifications := []models.Notification{{ + Title: "You were mentioned", + Message: "Sam mentioned you in a comment on Cannot log in (OPS-7)", + Type: "mention", + Timestamp: time.Date(2026, 10, 6, 9, 30, 0, 0, time.UTC), + Metadata: models.NotificationMetadataJSON( + "notification.mention.title", + "notification.mention.comment", + map[string]any{"actor": "Sam", "itemTitle": "Cannot log in", "itemKey": "OPS-7"}, + ), + }} + + _, htmlBody, textBody, err := sender.RenderEmailLocale( + "de", emailutil.TemplateNotificationBatch, + buildNotificationBatchData("https://app.example.com", "Alex", "de", "UTC", notifications), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + for _, want := range []string{ + "Sie wurden erwähnt", + "Sam hat Sie in einem Kommentar zu Cannot log in (OPS-7) erwähnt", + } { + if !strings.Contains(textBody, want) { + t.Fatalf("plain-text digest missing %q:\n%s", want, textBody) + } + } + if strings.Contains(textBody, "mentioned you in a comment") { + t.Fatalf("digest still carries the stored English copy:\n%s", textBody) + } + if !strings.Contains(htmlBody, "Sie wurden erwähnt") { + t.Fatalf("HTML digest is not localized:\n%s", htmlBody) + } +} + +// TestDigestFallsBackToStoredCopyWithoutMetadata proves rule-based and +// user-authored notifications (which carry no i18n metadata) render their +// stored text unchanged. +func TestDigestFallsBackToStoredCopyWithoutMetadata(t *testing.T) { + sender := &NotificationSMTPSender{} + notifications := []models.Notification{{ + Title: "Rule-based notice", + Message: "This text is authored by an admin.", + Type: "info", + Timestamp: time.Date(2026, 10, 6, 9, 30, 0, 0, time.UTC), + }} + + _, _, textBody, err := sender.RenderEmailLocale( + "de", emailutil.TemplateNotificationBatch, + buildNotificationBatchData("https://app.example.com", "Alex", "de", "UTC", notifications), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if !strings.Contains(textBody, "Rule-based notice") || !strings.Contains(textBody, "authored by an admin") { + t.Fatalf("stored copy was not preserved:\n%s", textBody) + } +} + +// TestDigestLocalizedCopyFallsBackWhenTranslationMissing ensures a row with an +// unknown i18n key still shows its stored copy instead of the raw key. +func TestDigestLocalizedCopyFallsBackWhenTranslationMissing(t *testing.T) { + sender := &NotificationSMTPSender{} + notifications := []models.Notification{{ + Title: "Stored title", + Message: "Stored message", + Type: "info", + Timestamp: time.Date(2026, 10, 6, 9, 30, 0, 0, time.UTC), + Metadata: models.NotificationMetadataJSON("notification.unknown.title", "notification.unknown.message", nil), + }} + + _, _, textBody, err := sender.RenderEmailLocale( + "de", emailutil.TemplateNotificationBatch, + buildNotificationBatchData("https://app.example.com", "Alex", "de", "UTC", notifications), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if !strings.Contains(textBody, "Stored title") || !strings.Contains(textBody, "Stored message") { + t.Fatalf("missing translation did not fall back to stored copy:\n%s", textBody) + } + if strings.Contains(textBody, "notification.unknown") { + t.Fatalf("digest leaked a raw i18n key:\n%s", textBody) + } +} diff --git a/internal/smtp/locale_override_test.go b/internal/smtp/locale_override_test.go new file mode 100644 index 0000000..288b4e8 --- /dev/null +++ b/internal/smtp/locale_override_test.go @@ -0,0 +1,84 @@ +//go:build test + +package smtp + +import ( + "strings" + "testing" + + "windshift/internal/emailutil" + "windshift/internal/repository" +) + +// TestLocaleOverrideWinsOverBuiltInDefault covers the sender resolution order: +// a per-locale admin override wins for that locale, while other locales keep +// the localized built-in default. +func TestLocaleOverrideWinsOverBuiltInDefault(t *testing.T) { + db := newSMTPTestDB(t) + if err := emailutil.SeedTemplates(db); err != nil { + t.Fatalf("SeedTemplates: %v", err) + } + + repo := repository.NewEmailTemplateRepository(db) + base, err := repo.GetByName(emailutil.TemplateMagicLink) + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if _, err := repo.UpsertTranslation(base.ID, "de", "DE Betreff", "

DE Inhalt

", "DE Inhalt"); err != nil { + t.Fatalf("UpsertTranslation: %v", err) + } + + sender := NewNotificationSMTPSender(db) + subject, htmlBody, _, err := sender.RenderEmailLocale( + "de", emailutil.TemplateMagicLink, emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale(de): %v", err) + } + if subject != "DE Betreff" { + t.Fatalf("subject = %q, want the de override", subject) + } + if !strings.Contains(htmlBody, "DE Inhalt") { + t.Fatalf("de override body not used:\n%s", htmlBody) + } + + // English has no override and the seed is unmodified: built-in default. + enSubject, _, _, err := sender.RenderEmailLocale( + "en", emailutil.TemplateMagicLink, emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale(en): %v", err) + } + if enSubject != "Sign in to your portal" { + t.Fatalf("en subject = %q, want the built-in default", enSubject) + } +} + +// TestCustomizedBaseWinsOverBuiltInDefaultForUntranslatedLocale proves an +// admin-edited base row is used for locales without their own override. +func TestCustomizedBaseWinsOverBuiltInDefaultForUntranslatedLocale(t *testing.T) { + db := newSMTPTestDB(t) + if err := emailutil.SeedTemplates(db); err != nil { + t.Fatalf("SeedTemplates: %v", err) + } + + repo := repository.NewEmailTemplateRepository(db) + base, err := repo.GetByName(emailutil.TemplateMagicLink) + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if _, err := repo.Update(base.ID, "Custom subject", "

Custom body

", "Custom body", "desc", true); err != nil { + t.Fatalf("Update: %v", err) + } + + sender := NewNotificationSMTPSender(db) + subject, htmlBody, _, err := sender.RenderEmailLocale( + "fr", emailutil.TemplateMagicLink, emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale(fr): %v", err) + } + if subject != "Custom subject" || !strings.Contains(htmlBody, "Custom body") { + t.Fatalf("customized base not used for fr: subject=%q html=%q", subject, htmlBody) + } +} diff --git a/internal/smtp/localized_email_test.go b/internal/smtp/localized_email_test.go new file mode 100644 index 0000000..c4b18ba --- /dev/null +++ b/internal/smtp/localized_email_test.go @@ -0,0 +1,130 @@ +//go:build test + +package smtp + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/emailutil" + "windshift/internal/i18n" +) + +// TestBuiltInTemplateRendersRecipientLocale pins the end-to-end localization: +// the same template renders German copy, sets lang/dir on the shell, and keeps +// the localized expiry wording. +func TestBuiltInTemplateRendersRecipientLocale(t *testing.T) { + sender := &NotificationSMTPSender{} + data := struct { + FirstName string + MagicLinkURL string + ExpiresIn string + }{ + FirstName: "Alex", + MagicLinkURL: "https://app.example.com/portal/acme/verify#token=t", + ExpiresIn: i18n.HumanizeDuration("de", 30*time.Minute), + } + + subject, htmlBody, textBody, err := sender.RenderEmailLocale("de", emailutil.TemplateMagicLink, data) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if want := "Bei Ihrem Portal anmelden"; subject != want { + t.Fatalf("subject = %q, want %q", subject, want) + } + if !strings.Contains(htmlBody, `lang="de"`) || !strings.Contains(htmlBody, `dir="ltr"`) { + t.Fatalf("HTML shell missing German lang/dir:\n%s", htmlBody) + } + if !strings.Contains(textBody, "Der Link ist 30 Minuten gÃŧltig.") { + t.Fatalf("plain-text body is not German with a localized expiry: %q", textBody) + } + if strings.Contains(htmlBody, "Sign in to your portal") { + t.Fatalf("German email still contains English copy:\n%s", htmlBody) + } +} + +func TestArabicTemplateRendersRightToLeft(t *testing.T) { + sender := &NotificationSMTPSender{} + _, htmlBody, _, err := sender.RenderEmailLocale( + "ar", + emailutil.TemplateMagicLink, + emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if !strings.Contains(htmlBody, `lang="ar"`) || !strings.Contains(htmlBody, `dir="rtl"`) { + t.Fatalf("Arabic HTML shell missing lang=\"ar\" dir=\"rtl\":\n%s", htmlBody) + } +} + +// TestUncustomizedSeedUsesLocalizedDefault covers the production upgrade path: +// rows seeded from the shipped defaults must not shadow the localized default +// until an admin edits them. +func TestUncustomizedSeedUsesLocalizedDefault(t *testing.T) { + db := newSMTPTestDB(t) + if err := emailutil.SeedTemplates(db); err != nil { + t.Fatalf("SeedTemplates: %v", err) + } + + sender := NewNotificationSMTPSender(db) + subject, _, _, err := sender.RenderEmailLocale( + "de", + emailutil.TemplateMagicLink, + emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if want := "Bei Ihrem Portal anmelden"; subject != want { + t.Fatalf("subject = %q, want the localized default %q", subject, want) + } +} + +// TestCustomizedTemplateWinsOverLocalizedDefault proves an admin edit still +// overrides localization once is_customized is set. +func TestCustomizedTemplateWinsOverLocalizedDefault(t *testing.T) { + db := newSMTPTestDB(t) + if err := emailutil.SeedTemplates(db); err != nil { + t.Fatalf("SeedTemplates: %v", err) + } + if _, err := db.ExecWrite(` + UPDATE notification_templates + SET subject = 'Custom subject', content = '

Custom body

', text_body = 'Custom body', is_customized = true + WHERE name = 'magic_link' + `); err != nil { + t.Fatalf("customize template: %v", err) + } + + sender := NewNotificationSMTPSender(db) + subject, htmlBody, textBody, err := sender.RenderEmailLocale( + "de", + emailutil.TemplateMagicLink, + emailutil.SampleData(emailutil.TemplateMagicLink), + ) + if err != nil { + t.Fatalf("RenderEmailLocale: %v", err) + } + if subject != "Custom subject" { + t.Fatalf("subject = %q, want the admin-edited subject", subject) + } + if !strings.Contains(htmlBody, "Custom body") || !strings.Contains(textBody, "Custom body") { + t.Fatalf("admin edit was not used: html=%q text=%q", htmlBody, textBody) + } +} + +func newSMTPTestDB(t *testing.T) database.Database { + t.Helper() + db, err := database.NewSQLiteDB(filepath.Join(t.TempDir(), "smtp-i18n.db")) + if err != nil { + t.Fatalf("NewSQLiteDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + return db +} diff --git a/internal/smtp/mention_template_rendering_test.go b/internal/smtp/mention_template_rendering_test.go index e8244a1..d246362 100644 --- a/internal/smtp/mention_template_rendering_test.go +++ b/internal/smtp/mention_template_rendering_test.go @@ -68,3 +68,40 @@ func TestMentionEmailRendersMessageAndActionLink(t *testing.T) { t.Fatalf("plain-text body missing action link: %q", textBody) } } + +// TestMentionEmailRendersEmbeddedIdTokenAsDisplayName covers the canonical +// @{Display Name#id} token: neither body part may expose the embedded id, and +// the recipient sees the readable display name. +func TestMentionEmailRendersEmbeddedIdTokenAsDisplayName(t *testing.T) { + sender := &NotificationSMTPSender{} + data := struct { + RecipientName string + ActorName string + ItemKey string + ItemTitle string + SourceLabel string + Content string + ActionURL string + }{ + RecipientName: "Alex", + ActorName: "Sam Patel", + ItemKey: "ACME-42", + ItemTitle: "Cannot reset password", + SourceLabel: "a comment", + Content: "cc @{John Doe#42} please", + ActionURL: "https://app.example.com/workspaces/1/items/42", + } + + _, htmlBody, textBody, err := sender.RenderEmail(emailutil.TemplateMention, data) + if err != nil { + t.Fatalf("RenderEmail: %v", err) + } + for name, body := range map[string]string{"html": htmlBody, "text": textBody} { + if !strings.Contains(body, "@John Doe") { + t.Errorf("%s body missing display name:\n%s", name, body) + } + if strings.Contains(body, "#42") || strings.Contains(body, "@{") { + t.Errorf("%s body leaked the embedded id:\n%s", name, body) + } + } +} diff --git a/internal/smtp/notification_digest_links_test.go b/internal/smtp/notification_digest_links_test.go index aa08735..3842b5e 100644 --- a/internal/smtp/notification_digest_links_test.go +++ b/internal/smtp/notification_digest_links_test.go @@ -42,7 +42,7 @@ func TestNotificationDigestRendersActionLinks(t *testing.T) { _, htmlBody, textBody, err := sender.RenderEmail( emailutil.TemplateNotificationBatch, - buildNotificationBatchData(sender.baseURL, "Alex", notifications), + buildNotificationBatchData(sender.baseURL, "Alex", "en", "UTC", notifications), ) if err != nil { t.Fatalf("RenderEmail: %v", err) @@ -67,7 +67,7 @@ func TestNotificationDigestOmitsLinkWhenActionURLMissing(t *testing.T) { _, htmlBody, textBody, err := sender.RenderEmail( emailutil.TemplateNotificationBatch, - buildNotificationBatchData("https://app.example.com", "Alex", notifications), + buildNotificationBatchData("https://app.example.com", "Alex", "en", "UTC", notifications), ) if err != nil { t.Fatalf("RenderEmail: %v", err) diff --git a/internal/smtp/notification_template_rendering_test.go b/internal/smtp/notification_template_rendering_test.go index 4996969..47cc9a5 100644 --- a/internal/smtp/notification_template_rendering_test.go +++ b/internal/smtp/notification_template_rendering_test.go @@ -24,7 +24,7 @@ func TestRenderEmailKeepsTextLiteralAndEscapesHTML(t *testing.T) { subject, htmlBody, textBody, err := sender.RenderEmail( emailutil.TemplateNotificationBatch, - buildNotificationBatchData("https://app.example.com", "D'Arcy & Sons ", notifications), + buildNotificationBatchData("https://app.example.com", "D'Arcy & Sons ", "en", "UTC", notifications), ) if err != nil { t.Fatalf("RenderEmail: %v", err) @@ -34,11 +34,11 @@ func TestRenderEmailKeepsTextLiteralAndEscapesHTML(t *testing.T) { t.Fatalf("subject = %q, want %q", got, want) } wantText := "Hi D'Arcy & Sons ,\n\n" + - "You have 1 new notification from Windshift:\n\n" + + "1 new notification:\n\n" + "* R&D O'Hara — æąäēŦ\n" + " Review https://example.test/a?one=1&two=2 \n" + - " September 5, 2026 at 2:30 PM\n\n\n" + - "Manage your notification preferences in Windshift.\n" + " 9/5/2026, 2:30 PM UTC\n\n\n" + + "Manage how often you receive these emails in your notification preferences.\n" if textBody != wantText { t.Fatalf("text body mismatch\n got: %q\nwant: %q", textBody, wantText) } diff --git a/internal/smtp/render_email_fallback_test.go b/internal/smtp/render_email_fallback_test.go index f681d92..88b65cb 100644 --- a/internal/smtp/render_email_fallback_test.go +++ b/internal/smtp/render_email_fallback_test.go @@ -24,8 +24,8 @@ func TestRenderEmailFallsBackToDefaultWhenAdminTemplateIsBroken(t *testing.T) { t.Fatalf("Initialize: %v", err) } if _, err := db.ExecWrite(` - INSERT INTO notification_templates (name, subject, content, text_body, is_active) - VALUES ('magic_link', 'Sign in', '

{{.FirstName

', 'body', true) + INSERT INTO notification_templates (name, subject, content, text_body, is_active, is_customized) + VALUES ('magic_link', 'Sign in', '

{{.FirstName

', 'body', true, true) `); err != nil { t.Fatalf("insert broken template: %v", err) } diff --git a/internal/sso/security_test/handlers_test.go b/internal/sso/security_test/handlers_test.go index f7c89e8..af03a2b 100644 --- a/internal/sso/security_test/handlers_test.go +++ b/internal/sso/security_test/handlers_test.go @@ -8,12 +8,12 @@ import ( "strings" "testing" - "windshift/internal/middleware" + "windshift/internal/ratelimit" ) func TestSSOHandler_RateLimiting(t *testing.T) { // Create rate limiter with 5 requests per minute, burst of 2 - limiter := middleware.NewRateLimiter(5.0/60.0, 2, false, nil) + limiter := ratelimit.New(5.0/60.0, 2, ratelimit.ByIP(nil)) defer limiter.Stop() handler := limiter.Limit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -45,7 +45,7 @@ func TestSSOHandler_RateLimiting(t *testing.T) { func TestSSOHandler_RateLimiting_DifferentIPs(t *testing.T) { // Create rate limiter with burst of 1 - limiter := middleware.NewRateLimiter(1.0/60.0, 1, false, nil) + limiter := ratelimit.New(1.0/60.0, 1, ratelimit.ByIP(nil)) defer limiter.Stop() handler := limiter.Limit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/testutils/querycount.go b/internal/testutils/querycount.go new file mode 100644 index 0000000..fe840c2 --- /dev/null +++ b/internal/testutils/querycount.go @@ -0,0 +1,90 @@ +//go:build test + +package testutils + +import ( + "context" + "database/sql" + "strings" + "sync" + "sync/atomic" + + "windshift/internal/database" +) + +// QueryCountingDB wraps a database.Database and records read queries issued +// through it. Tests use it to assert that a batched path issues a constant +// number of queries as its input grows, and to prove a specific per-row query +// is gone. Writes (Exec) are not counted, so fixture setup does not pollute the +// measurement. +type QueryCountingDB struct { + database.Database + queries atomic.Int64 + mu sync.Mutex + texts []string +} + +// CountQueries wraps db so Query, QueryRow, QueryContext and QueryRowContext +// calls are counted and recorded. +func CountQueries(db database.Database) *QueryCountingDB { + return &QueryCountingDB{Database: db} +} + +// Queries returns the number of read queries issued so far. +func (c *QueryCountingDB) Queries() int64 { return c.queries.Load() } + +// QueryTexts returns the SQL text of every read query issued so far. +func (c *QueryCountingDB) QueryTexts() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.texts...) +} + +// CountQueriesContaining returns how many recorded queries contain substr. +func (c *QueryCountingDB) CountQueriesContaining(substr string) int { + c.mu.Lock() + defer c.mu.Unlock() + count := 0 + for _, text := range c.texts { + if strings.Contains(text, substr) { + count++ + } + } + return count +} + +// ResetQueries zeroes the counter and drops the recorded texts, so a test can +// measure one call at a time. +func (c *QueryCountingDB) ResetQueries() { + c.mu.Lock() + c.texts = nil + c.mu.Unlock() + c.queries.Store(0) +} + +func (c *QueryCountingDB) record(query string) { + c.queries.Add(1) + c.mu.Lock() + c.texts = append(c.texts, query) + c.mu.Unlock() +} + +func (c *QueryCountingDB) Query(query string, args ...any) (*sql.Rows, error) { + c.record(query) + return c.Database.Query(query, args...) +} + +func (c *QueryCountingDB) QueryRow(query string, args ...any) *sql.Row { + c.record(query) + return c.Database.QueryRow(query, args...) +} + +func (c *QueryCountingDB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + c.record(query) + return c.Database.QueryContext(ctx, query, args...) +} + +func (c *QueryCountingDB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + c.record(query) + return c.Database.QueryRowContext(ctx, query, args...) +} diff --git a/internal/wscli/comment_body_input_test.go b/internal/wscli/comment_body_input_test.go new file mode 100644 index 0000000..088c769 --- /dev/null +++ b/internal/wscli/comment_body_input_test.go @@ -0,0 +1,145 @@ +//go:build test + +package wscli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// The body shape that started WI-1796's correction loop: backticked code +// spans plus a literal two-char \n. A double-quoted -m loses both to the +// shell, so --file must deliver these bytes verbatim. +const backtickBody = "the current `internal/llm/manager.go` still falls back to `FallbackMaxOutputTokens = 65_536`\nliteral \\n stays two chars" + +func TestResolveCommentBody_FileWinsOverMessage(t *testing.T) { + path := filepath.Join(t.TempDir(), "body.md") + if err := os.WriteFile(path, []byte(backtickBody), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + body, err := resolveCommentBody("ignored -m value", path) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if body != backtickBody { + t.Errorf("body = %q, want file bytes %q", body, backtickBody) + } +} + +func TestResolveCommentBody_StdinUsedVerbatim(t *testing.T) { + prev := stdin + stdin = strings.NewReader(backtickBody) + t.Cleanup(func() { stdin = prev }) + + body, err := resolveCommentBody("", "-") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if body != backtickBody { + t.Errorf("body = %q, want stdin bytes %q", body, backtickBody) + } +} + +func TestResolveCommentBody_MessageAppliesCLIEscapes(t *testing.T) { + body, err := resolveCommentBody("line one\\nline two", "") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if want := "line one\nline two"; body != want { + t.Errorf("body = %q, want %q", body, want) + } +} + +func TestResolveCommentBody_RequiresInput(t *testing.T) { + _, err := resolveCommentBody("", "") + if err == nil { + t.Fatal("expected error for missing input") + } + if !strings.Contains(err.Error(), "--file") { + t.Errorf("error = %v, want it to mention --file", err) + } +} + +func TestResolveCommentBody_MissingFile(t *testing.T) { + _, err := resolveCommentBody("", filepath.Join(t.TempDir(), "nope.md")) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "failed to read") { + t.Errorf("error = %v, want read failure context", err) + } +} + +// Comment add and edit must post --file bytes verbatim: before the flag +// existed the command failed with "unknown flag", and the -m path could +// never carry a body containing backticks safely. +func TestCommentCommands_FilePostsVerbatimBody(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {"add via stdin", []string{"comment", "add", "WI-42", "--file", "-"}}, + {"add via file path", nil}, // path filled in below + {"edit via stdin", []string{"comment", "edit", "9", "--file", "-"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var gotMethod string + var gotBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + switch { + case r.Method == http.MethodGet && r.URL.Path == "/rest/api/v2/workspaces/WI/items/42": + _, _ = w.Write([]byte(`{"data":{"id":77}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/rest/api/v2/items/77/comments", + r.Method == http.MethodPatch && r.URL.Path == "/rest/api/v2/comments/9": + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _, _ = w.Write([]byte(`{"data":{"id":9,"item_id":77,"content":"echo"}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + args := tc.args + if args == nil { + path := filepath.Join(t.TempDir(), "body.md") + if err := os.WriteFile(path, []byte(backtickBody), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + args = []string{"comment", "add", "WI-42", "--file", path} + } + + var out, errOut strings.Builder + var stdinReader io.Reader + if args[len(args)-1] == "-" { + stdinReader = strings.NewReader(backtickBody) + } + code := Run(context.Background(), args, stdinReader, &out, &errOut, map[string]string{ + "WS_URL": server.URL, + "WS_TOKEN": "test-token", + }) + + if code != 0 { + t.Fatalf("exit code = %d, stderr = %q", code, errOut.String()) + } + wantVerb := "POST" + if args[1] == "edit" { + wantVerb = "PATCH" + } + if gotMethod != wantVerb { + t.Errorf("request = %s, want %s", gotMethod, wantVerb) + } + if gotBody["content"] != backtickBody { + t.Errorf("posted content = %q, want verbatim %q", gotBody["content"], backtickBody) + } + }) + } +} diff --git a/internal/wscli/link_test.go b/internal/wscli/link_test.go index 2f494b4..9ac421e 100644 --- a/internal/wscli/link_test.go +++ b/internal/wscli/link_test.go @@ -89,3 +89,71 @@ func TestAutoLinkTypeName(t *testing.T) { } } } + +// TestOutput_LinkRendersWorkspaceKeyNumber is the regression for WI-1930: +// item endpoints must render as workspace keys (WI-1928), never as the global +// DB id, which the API also returns as source_id/target_id. +func TestOutput_LinkRendersWorkspaceKeyNumber(t *testing.T) { + sourceNumber, targetNumber := 1927, 1928 + + t.Run("list table", func(t *testing.T) { + resp := &LinkListResponse{Outgoing: []ItemLink{{ + ID: 525, LinkTypeName: "Relates To", LinkTypeForwardLabel: "relates to", + SourceType: "item", SourceID: 3000, SourceWorkspaceKey: "WI", SourceItemNumber: &sourceNumber, + TargetType: "item", TargetID: 3001, TargetWorkspaceKey: "WI", TargetItemNumber: &targetNumber, + TargetTitle: "Migrate homepage and dashboard data from v1 to v2 API", + }}} + out := captureStdout(t, func() { (&Output{format: "table"}).Print(resp) }) + if !strings.Contains(out, "WI-1928") { + t.Errorf("expected workspace key WI-1928 in table output:\n%s", out) + } + if strings.Contains(out, "WI-3001") { + t.Errorf("table output leaked global DB id as a key:\n%s", out) + } + }) + + t.Run("list csv", func(t *testing.T) { + resp := &LinkListResponse{Incoming: []ItemLink{{ + ID: 524, LinkTypeName: "Relates To", LinkTypeReverseLabel: "relates to", + SourceType: "item", SourceID: 3002, SourceWorkspaceKey: "WI", SourceItemNumber: ptrInt(1929), + TargetType: "item", TargetID: 3000, TargetWorkspaceKey: "WI", TargetItemNumber: &sourceNumber, + SourceTitle: "Rethink notification delivery for agent identities", + }}} + out := captureStdout(t, func() { (&Output{format: "csv"}).Print(resp) }) + if !strings.Contains(out, "WI-1929") || strings.Contains(out, "WI-3002") { + t.Errorf("csv output did not render incoming source as WI-1929:\n%s", out) + } + }) + + t.Run("detail table", func(t *testing.T) { + link := &ItemLink{ + ID: 525, LinkTypeName: "Relates To", LinkTypeForwardLabel: "relates to", + SourceType: "item", SourceID: 3000, SourceWorkspaceKey: "WI", SourceItemNumber: &sourceNumber, + TargetType: "item", TargetID: 3001, TargetWorkspaceKey: "WI", TargetItemNumber: &targetNumber, + } + out := captureStdout(t, func() { (&Output{format: "table"}).Print(link) }) + if !strings.Contains(out, "WI-1927") || !strings.Contains(out, "WI-1928") { + t.Errorf("detail table missing workspace keys:\n%s", out) + } + if strings.Contains(out, "WI-3000") || strings.Contains(out, "WI-3001") { + t.Errorf("detail table leaked global DB ids as keys:\n%s", out) + } + }) + + t.Run("missing item number falls back to opaque id", func(t *testing.T) { + link := &ItemLink{ + ID: 1, LinkTypeName: "Relates To", LinkTypeForwardLabel: "relates to", + SourceType: "item", SourceID: 3000, SourceWorkspaceKey: "WI", + TargetType: "item", TargetID: 3001, TargetWorkspaceKey: "WI", + } + out := captureStdout(t, func() { (&Output{format: "table"}).Print(link) }) + if strings.Contains(out, "WI-3001") { + t.Errorf("missing item number must not fall back to a workspace key:\n%s", out) + } + if !strings.Contains(out, "item:3001") { + t.Errorf("expected opaque item:3001 fallback:\n%s", out) + } + }) +} + +func ptrInt(v int) *int { return &v } diff --git a/suite-manifest.json b/suite-manifest.json index 2f4ad22..fb994d1 100644 --- a/suite-manifest.json +++ b/suite-manifest.json @@ -8,6 +8,7 @@ "internal/licensing", "internal/markdown", "internal/objecttranslation", + "internal/planeimport", "internal/testsummary", "internal/utils" ], diff --git a/tests/anonymous_baseline_test.go b/tests/anonymous_baseline_test.go index 70d434d..028d021 100644 --- a/tests/anonymous_baseline_test.go +++ b/tests/anonymous_baseline_test.go @@ -125,6 +125,10 @@ var anonymousBaselineExemptions = []struct { {"*", "/api/setup-status"}, {"*", "/api/capabilities"}, + // Public instance branding (site title + active theme logos), fetched + // before authentication so the login and portal surfaces render branded. + {"GET", "/api/branding"}, + // SCIM service provider config + schemas are anonymous per RFC 7644. {"GET", "/scim/v2/ServiceProviderConfig"}, {"GET", "/scim/v2/ResourceTypes"}, diff --git a/tests/attachment_visibility_toggle_test.go b/tests/attachment_visibility_toggle_test.go new file mode 100644 index 0000000..e13703f --- /dev/null +++ b/tests/attachment_visibility_toggle_test.go @@ -0,0 +1,176 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" +) + +// TestAttachmentVisibilityToggleControlsPortalAccess covers the v2 +// PATCH /attachments/{attachment_id} endpoint added in WI-2032: an editor can +// hide an attachment from portal customers and reveal it again, and the +// portal list/download paths honor the flag. +func TestAttachmentVisibilityToggleControlsPortalAccess(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + workspaceID, _ := CreateTestWorkspace(t, server, "Attachment Visibility Toggle", shortKey("AVT")) + portalSlug, channelID := SetupPortalChannel(t, server, workspaceID) + _, ownerCookie := CreatePortalCustomerWithSession(t, server, channelID, "Visibility Owner", + fmt.Sprintf("visibility-owner-%d@example.com", time.Now().UnixNano())) + + itemID := SubmitPortalRequest(t, server, portalSlug, ownerCookie, "Request with a togglable file") + uploadPath := fmt.Sprintf("/portal/%s/requests/%d/attachments", portalSlug, itemID) + + upload := makePortalMultipartRequest(t, server, ownerCookie, uploadPath, "evidence.png", validTestPNG(t)) + defer upload.Body.Close() + AssertStatusCode(t, upload, http.StatusCreated) + var uploaded struct { + Attachment struct { + ID int `json:"id"` + } `json:"attachment"` + } + DecodeJSON(t, upload, &uploaded) + if uploaded.Attachment.ID == 0 { + t.Fatal("upload response has no attachment id") + } + attachmentID := uploaded.Attachment.ID + + visibilityPath := fmt.Sprintf("/v2/attachments/%d", attachmentID) + + // A freshly uploaded file is visible to the customer. + portalList := MakePortalRequest(t, server, ownerCookie, http.MethodGet, uploadPath, nil) + defer portalList.Body.Close() + AssertStatusCode(t, portalList, http.StatusOK) + var before []map[string]any + DecodeJSON(t, portalList, &before) + if len(before) != 1 { + t.Fatalf("portal attachment count before hide = %d, want 1", len(before)) + } + + // The editor hides it. + hide := MakeAuthRequest(t, server, http.MethodPatch, visibilityPath, map[string]any{"is_internal": true}) + defer hide.Body.Close() + AssertStatusCode(t, hide, http.StatusOK) + var hidden struct { + Data struct { + ID int `json:"id"` + IsInternal bool `json:"is_internal"` + } `json:"data"` + } + DecodeJSON(t, hide, &hidden) + if hidden.Data.ID != attachmentID || !hidden.Data.IsInternal { + t.Fatalf("hide response = %+v, want id %d with is_internal true", hidden.Data, attachmentID) + } + + // The portal no longer lists or serves the file. + portalList = MakePortalRequest(t, server, ownerCookie, http.MethodGet, uploadPath, nil) + defer portalList.Body.Close() + AssertStatusCode(t, portalList, http.StatusOK) + var after []map[string]any + DecodeJSON(t, portalList, &after) + if len(after) != 0 { + t.Fatalf("portal attachment count after hide = %d, want 0", len(after)) + } + denied := MakePortalRequest(t, server, ownerCookie, http.MethodGet, + fmt.Sprintf("/portal/%s/requests/%d/attachments/%d/download", portalSlug, itemID, attachmentID), nil) + defer denied.Body.Close() + AssertStatusCode(t, denied, http.StatusNotFound) + + // Workspace members still see it, flagged as internal. + memberList := MakeBearerRequest(t, server, http.MethodGet, fmt.Sprintf("/rest/api/v2/items/%d/attachments", itemID), nil) + defer memberList.Body.Close() + AssertStatusCode(t, memberList, http.StatusOK) + var memberPage struct { + Data []struct { + ID int `json:"id"` + IsInternal bool `json:"is_internal"` + } `json:"data"` + } + if err := json.NewDecoder(memberList.Body).Decode(&memberPage); err != nil { + t.Fatalf("decode member attachment list: %v", err) + } + if len(memberPage.Data) != 1 || !memberPage.Data[0].IsInternal { + t.Fatalf("member attachments = %+v, want one with is_internal true", memberPage.Data) + } + + // Revealing it restores portal access. + reveal := MakeAuthRequest(t, server, http.MethodPatch, visibilityPath, map[string]any{"is_internal": false}) + defer reveal.Body.Close() + AssertStatusCode(t, reveal, http.StatusOK) + portalList = MakePortalRequest(t, server, ownerCookie, http.MethodGet, uploadPath, nil) + defer portalList.Body.Close() + AssertStatusCode(t, portalList, http.StatusOK) + var revealed []map[string]any + DecodeJSON(t, portalList, &revealed) + if len(revealed) != 1 || int(revealed[0]["id"].(float64)) != attachmentID { + t.Fatalf("portal attachments after reveal = %+v, want attachment %d", revealed, attachmentID) + } + + t.Run("missing is_internal is a validation error", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPatch, visibilityPath, map[string]any{}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusBadRequest) + }) + + t.Run("missing attachment is not found", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPatch, "/v2/attachments/99999999", map[string]any{"is_internal": true}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusNotFound) + }) + + t.Run("anonymous caller is unauthorized", func(t *testing.T) { + resp := makeSessionRequest(t, http.MethodPatch, server.APIBase+visibilityPath, "", + map[string]any{"is_internal": true}, map[string]string{"Content-Type": "application/merge-patch+json"}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusUnauthorized) + }) + + t.Run("bearer token without items write scope is forbidden", func(t *testing.T) { + readOnly := createTokenWithScopesAsUser(t, server, "admin", "testpass123", []string{"items:read"}) + resp := MakeBearerRequestWithToken(t, server, readOnly, http.MethodPatch, + fmt.Sprintf("/rest/api/v2/attachments/%d", attachmentID), map[string]any{"is_internal": true}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusForbidden) + }) + + t.Run("viewer without item edit permission gets not found", func(t *testing.T) { + viewerID, viewerUsername, viewerPassword := CreateTestUserWithCredentials(t, server, "visibility_viewer", fmt.Sprintf("visibility-viewer-%d@example.com", time.Now().UnixNano())) + AssignWorkspaceRole(t, server, viewerID, workspaceID, "Viewer") + viewerCookie, _ := CreateAuthCredentialsForUser(t, server, viewerUsername, viewerPassword) + resp := makeSessionRequest(t, http.MethodPatch, server.APIBase+visibilityPath, viewerCookie, + map[string]any{"is_internal": true}, map[string]string{"Content-Type": "application/merge-patch+json"}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusNotFound) + }) + + t.Run("page attachment cannot be toggled", func(t *testing.T) { + createPage := MakeAuthRequest(t, server, http.MethodPost, fmt.Sprintf("/workspaces/%d/pages", workspaceID), map[string]any{ + "title": "Attachment page", "content": "body", + }) + defer createPage.Body.Close() + AssertStatusCode(t, createPage, http.StatusCreated) + var page map[string]any + DecodeJSON(t, createPage, &page) + pageID := ExtractIDFromResponse(t, page) + + pageUpload := makeBearerMultipartRequest(t, server, + fmt.Sprintf("/rest/api/v2/workspaces/%d/pages/%d/attachments", workspaceID, pageID), nil, + "diagram.json", []byte(`{"elements":[],"appState":{},"files":{}}`)) + defer pageUpload.Body.Close() + AssertStatusCode(t, pageUpload, http.StatusCreated) + var pageAttachment struct { + Data struct { + ID int `json:"id"` + } `json:"data"` + } + DecodeJSON(t, pageUpload, &pageAttachment) + + resp := MakeAuthRequest(t, server, http.MethodPatch, + fmt.Sprintf("/v2/attachments/%d", pageAttachment.Data.ID), map[string]any{"is_internal": true}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusNotFound) + }) +} diff --git a/tests/audit_admin_config_test.go b/tests/audit_admin_config_test.go new file mode 100644 index 0000000..992c037 --- /dev/null +++ b/tests/audit_admin_config_test.go @@ -0,0 +1,177 @@ +//go:build test + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "windshift/internal/logger" + "windshift/internal/models" +) + +// TestV2SLACalendarCreateWritesRecorderAudit covers WI-1900: an SLA +// configuration mutation emits a central audit row through the v2 recorder, +// with the authenticated actor populated. +func TestV2SLACalendarCreateWritesRecorderAudit(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + _ = CreateBearerToken(t, server) + bearer := server.BearerToken + workspaceID, _ := CreateTestWorkspace(t, server, "SLA Audit Workspace", shortKey("SLA")) + + payload := map[string]any{ + "name": "Audited support hours", + "timezone": "UTC", + "weekly_intervals": map[string]any{ + "monday": []map[string]string{{"start": "09:00", "end": "17:00"}}, + }, + } + response := MakeBearerRequestWithToken(t, server, bearer, http.MethodPost, + fmt.Sprintf("/rest/api/v2/workspaces/%d/sla/calendars", workspaceID), payload) + AssertStatusCode(t, response, http.StatusCreated) + var calendar models.WorkingCalendar + DecodeJSON(t, response, &calendar) + _ = response.Body.Close() + if calendar.ID == 0 { + t.Fatal("calendar was not created") + } + + var resourceType, username, ipAddress string + var success bool + if err := server.server.DB().QueryRow(` + SELECT resource_type, username, ip_address, success + FROM audit_logs + WHERE action_type = ? AND resource_id = ? + ORDER BY id DESC + LIMIT 1 + `, logger.ActionSLACalendarCreate, calendar.ID).Scan(&resourceType, &username, &ipAddress, &success); err != nil { + t.Fatalf("load SLA calendar audit: %v", err) + } + if resourceType != logger.ResourceSLACalendar || !success { + t.Fatalf("SLA calendar audit = resource %q success %v", resourceType, success) + } + if username == "" || ipAddress == "" { + t.Fatalf("SLA calendar audit actor not populated: username=%q ip=%q", username, ipAddress) + } +} + +// TestV1ObjectTranslationUpsertWritesAudit covers WI-1900: an object +// translation upsert emits an audit row naming the object, field, and locale. +func TestV1ObjectTranslationUpsertWritesAudit(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + token := CreateBearerToken(t, server) + + createResponse := MakeAuthRequestWithToken(t, server, token, http.MethodPost, "/priorities", map[string]any{ + "name": "Audited translation priority", "description": "", "icon": "AlertCircle", + "color": "#123456", "sort_order": 90, "is_default": false, + }) + AssertStatusCode(t, createResponse, http.StatusCreated) + var priority struct { + ID int `json:"id"` + } + DecodeJSON(t, createResponse, &priority) + _ = createResponse.Body.Close() + + upsertPath := fmt.Sprintf("/admin/object-translations/priority/%d/name/pt-br", priority.ID) + upsertResponse := MakeAuthRequestWithToken(t, server, token, http.MethodPut, upsertPath, map[string]any{ + "value": "Prioridade auditada", + }) + AssertStatusCode(t, upsertResponse, http.StatusOK) + _ = upsertResponse.Body.Close() + + var resourceType, detailsJSON string + var success bool + if err := server.server.DB().QueryRow(` + SELECT resource_type, details, success + FROM audit_logs + WHERE action_type = ? AND resource_id = ? + ORDER BY id DESC + LIMIT 1 + `, logger.ActionObjectTranslationUpdate, priority.ID).Scan(&resourceType, &detailsJSON, &success); err != nil { + t.Fatalf("load object translation audit: %v", err) + } + if resourceType != logger.ResourceObjectTranslation || !success { + t.Fatalf("object translation audit = resource %q success %v", resourceType, success) + } + var details map[string]any + if err := json.Unmarshal([]byte(detailsJSON), &details); err != nil { + t.Fatalf("decode translation audit details: %v", err) + } + if details["object_type"] != "priority" || details["field"] != "name" || details["locale"] != "pt-br" { + t.Fatalf("translation audit details = %#v", details) + } +} + +// TestSecuritySettingsUpdateAuditsOldAndNew covers WI-1900: a settings update +// records the previous and new value, not just the final state. +func TestSecuritySettingsUpdateAuditsOldAndNew(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + response := MakeAuthRequest(t, server, http.MethodPut, "/admin/security-settings", map[string]any{ + "calendar_feed_enabled": true, + "plugin_cli_exec_enabled": false, + "allow_external_images": true, + "api_key_creation_policy": "all_users", + "api_key_allowed_group_ids": []int{}, + "allow_user_managed_agents": false, + "max_agents_per_user": 5, + "workspace_managed_agents": true, + }) + defer response.Body.Close() + AssertStatusCode(t, response, http.StatusOK) + + var detailsJSON string + if err := server.server.DB().QueryRow(` + SELECT details FROM audit_logs WHERE action_type = ? ORDER BY id DESC LIMIT 1 + `, logger.ActionSecuritySettingsUpdate).Scan(&detailsJSON); err != nil { + t.Fatalf("load security settings audit: %v", err) + } + var details map[string]any + if err := json.Unmarshal([]byte(detailsJSON), &details); err != nil { + t.Fatalf("decode security settings audit details: %v", err) + } + change, ok := details["allow_external_images"].(map[string]any) + if !ok { + t.Fatalf("allow_external_images change missing: %#v", details) + } + if change["old"] != false || change["new"] != true { + t.Fatalf("allow_external_images change = %#v, want old=false new=true", change) + } + if _, present := details["calendar_feed_enabled"]; present { + t.Errorf("unchanged calendar_feed_enabled should not be in details: %#v", details) + } +} + +// TestWorkItemStalenessUpdateAuditsOldAndNew covers WI-1900: the staleness +// threshold update records the previous and new value. +func TestWorkItemStalenessUpdateAuditsOldAndNew(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + response := MakeAuthRequest(t, server, http.MethodPut, "/admin/work-item-staleness", map[string]any{ + "stale_after_days": 45, + }) + defer response.Body.Close() + AssertStatusCode(t, response, http.StatusOK) + + var detailsJSON string + if err := server.server.DB().QueryRow(` + SELECT details FROM audit_logs WHERE action_type = ? ORDER BY id DESC LIMIT 1 + `, logger.ActionWorkItemStalenessUpdate).Scan(&detailsJSON); err != nil { + t.Fatalf("load staleness audit: %v", err) + } + var details map[string]any + if err := json.Unmarshal([]byte(detailsJSON), &details); err != nil { + t.Fatalf("decode staleness audit details: %v", err) + } + change, ok := details["stale_after_days"].(map[string]any) + if !ok { + t.Fatalf("stale_after_days change missing: %#v", details) + } + if change["old"] != float64(30) || change["new"] != float64(45) { + t.Fatalf("stale_after_days change = %#v, want old=30 new=45", change) + } +} diff --git a/tests/audit_log_read_test.go b/tests/audit_log_read_test.go new file mode 100644 index 0000000..415ea79 --- /dev/null +++ b/tests/audit_log_read_test.go @@ -0,0 +1,130 @@ +//go:build test + +package tests + +import ( + "net/http" + "net/url" + "slices" + "testing" + + "windshift/internal/logger" +) + +// TestAuditLogReadSurfacesShareFilterContract covers WI-1901: the legacy, v1, +// and v2 read surfaces parse one shared filter contract, so the same query +// selects the same rows everywhere. Before the consolidation the v1 and v2 +// handlers ignored `search` and `success` and silently accepted a malformed +// `success`, while the legacy handler returned 400. +func TestAuditLogReadSurfacesShareFilterContract(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + _ = CreateBearerToken(t, server) + workspaceName := "Audit Read Workspace" + CreateTestWorkspace(t, server, workspaceName, shortKey("ARW")) + + legacyIDs := auditLogIDs(t, MakeAuthRequestRaw(t, server, http.MethodGet, + "/admin/audit-logs?action_type=workspace.create", ""), "entries") + v1IDs := auditLogIDs(t, MakeBearerRequest(t, server, http.MethodGet, + "/rest/api/v1/admin/audit-logs?action_type=workspace.create&limit=100", nil), "data") + v2IDs := auditLogIDs(t, MakeBearerRequest(t, server, http.MethodGet, + "/rest/api/v2/admin/audit-logs?action_type=workspace.create&page_size=100", nil), "data") + + if len(legacyIDs) == 0 { + t.Fatal("no workspace.create audit rows found to compare") + } + if !slices.Equal(legacyIDs, v1IDs) || !slices.Equal(v1IDs, v2IDs) { + t.Fatalf("read surfaces disagree for the same filter: legacy=%v v1=%v v2=%v", legacyIDs, v1IDs, v2IDs) + } + + // `search` must be honored on every surface. + encodedSearch := url.QueryEscape(workspaceName) + legacySearchIDs := auditLogIDs(t, MakeAuthRequestRaw(t, server, http.MethodGet, + "/admin/audit-logs?search="+encodedSearch, ""), "entries") + v2SearchIDs := auditLogIDs(t, MakeBearerRequest(t, server, http.MethodGet, + "/rest/api/v2/admin/audit-logs?search="+encodedSearch+"&page_size=100", nil), "data") + if !slices.Contains(legacySearchIDs, legacyIDs[0]) || !slices.Contains(v2SearchIDs, legacyIDs[0]) { + t.Fatalf("search did not return the matching row: legacy=%v v2=%v", legacySearchIDs, v2SearchIDs) + } + + // A malformed `success` is a client error on every surface. + for _, surface := range []struct { + name string + request func() *http.Response + }{ + {"legacy", func() *http.Response { + return MakeAuthRequestRaw(t, server, http.MethodGet, "/admin/audit-logs?success=maybe", "") + }}, + {"v1", func() *http.Response { + return MakeBearerRequest(t, server, http.MethodGet, "/rest/api/v1/admin/audit-logs?success=maybe", nil) + }}, + {"v2", func() *http.Response { + return MakeBearerRequest(t, server, http.MethodGet, "/rest/api/v2/admin/audit-logs?success=maybe", nil) + }}, + } { + response := surface.request() + AssertStatusCode(t, response, http.StatusBadRequest) + _ = response.Body.Close() + } + + // Reading the audit log is itself audited. + var readCount int + if err := server.server.DB().QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action_type = ?`, logger.ActionAuditLogRead).Scan(&readCount); err != nil { + t.Fatalf("count audit_log.read rows: %v", err) + } + if readCount < 3 { + t.Fatalf("expected an audit_log.read row per surface, got %d", readCount) + } +} + +// TestAuditLogStreamReadIsAudited covers WI-1901 for the cursor tail: each +// stream call records who tailed the log, with the cursor and batch size. +func TestAuditLogStreamReadIsAudited(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + _ = CreateBearerToken(t, server) + CreateTestWorkspace(t, server, "Audit Stream Workspace", shortKey("ASW")) + + response := MakeBearerRequest(t, server, http.MethodGet, "/rest/api/v2/admin/audit-logs/since?after_id=0&limit=5", nil) + defer response.Body.Close() + AssertStatusCode(t, response, http.StatusOK) + + var detailsJSON string + if err := server.server.DB().QueryRow(` + SELECT details FROM audit_logs WHERE action_type = ? ORDER BY id DESC LIMIT 1 + `, logger.ActionAuditLogStream).Scan(&detailsJSON); err != nil { + t.Fatalf("load audit_log.stream row: %v", err) + } + if detailsJSON == "" { + t.Fatal("audit_log.stream details are empty") + } +} + +func auditLogIDs(t *testing.T, response *http.Response, key string) []int { + t.Helper() + defer response.Body.Close() + AssertStatusCode(t, response, http.StatusOK) + var body map[string]any + DecodeJSON(t, response, &body) + raw, ok := body[key].([]any) + if !ok { + raw, ok = body["data"].([]any) + } + if !ok { + raw, ok = body["items"].([]any) + } + if !ok { + t.Fatalf("response missing entry array %q: %#v", key, body) + } + ids := make([]int, 0, len(raw)) + for _, entry := range raw { + row, ok := entry.(map[string]any) + if !ok { + t.Fatalf("entry is not an object: %#v", entry) + } + id, ok := row["id"].(float64) + if !ok { + t.Fatalf("entry missing numeric id: %#v", row) + } + ids = append(ids, int(id)) + } + return ids +} diff --git a/tests/board_view_settings_http_test.go b/tests/board_view_settings_http_test.go index 50b9386..a152c8f 100644 --- a/tests/board_view_settings_http_test.go +++ b/tests/board_view_settings_http_test.go @@ -57,6 +57,50 @@ func TestBoardViewSettingsHTTPContract(t *testing.T) { AssertStatusCode(t, unknown, http.StatusBadRequest) }) + t.Run("completed visibility and limit round-trip", func(t *testing.T) { + put := MakeAuthRequest(t, server, http.MethodPut, base, map[string]any{ + "view_settings": map[string]any{ + "completed_visibility": "limit", + "completed_limit": 250, + }, + }) + defer put.Body.Close() + AssertStatusCode(t, put, http.StatusOK) + + get := MakeAuthRequest(t, server, http.MethodGet, base, nil) + defer get.Body.Close() + AssertStatusCode(t, get, http.StatusOK) + var config struct { + Data struct { + ViewSettings struct { + CompletedVisibility *string `json:"completed_visibility"` + CompletedLimit *int `json:"completed_limit"` + } `json:"view_settings"` + } `json:"data"` + } + DecodeJSON(t, get, &config) + if config.Data.ViewSettings.CompletedVisibility == nil || *config.Data.ViewSettings.CompletedVisibility != "limit" { + t.Fatalf("completed_visibility = %v, want limit", config.Data.ViewSettings.CompletedVisibility) + } + if config.Data.ViewSettings.CompletedLimit == nil || *config.Data.ViewSettings.CompletedLimit != 250 { + t.Fatalf("completed_limit = %v, want 250", config.Data.ViewSettings.CompletedLimit) + } + + // An out-of-range cap is rejected instead of silently clamped. + outOfRange := MakeAuthRequest(t, server, http.MethodPut, base, map[string]any{ + "view_settings": map[string]any{"completed_visibility": "limit", "completed_limit": 0}, + }) + defer outOfRange.Body.Close() + AssertStatusCode(t, outOfRange, http.StatusBadRequest) + + // Restore the views-only override later subtests rely on. + restore := MakeAuthRequest(t, server, http.MethodPut, base, map[string]any{ + "view_settings": map[string]any{"enabled_views": []string{"board", "list"}}, + }) + defer restore.Body.Close() + AssertStatusCode(t, restore, http.StatusOK) + }) + t.Run("collection inherits until it overrides and resets", func(t *testing.T) { get := MakeAuthRequest(t, server, http.MethodGet, collectionBase, nil) defer get.Body.Close() @@ -231,4 +275,58 @@ func TestBoardViewSettingsHTTPContract(t *testing.T) { defer other.Body.Close() AssertStatusCode(t, other, http.StatusOK) }) + + t.Run("patch merges only the fields present", func(t *testing.T) { + put := MakeAuthRequest(t, server, http.MethodPut, base, map[string]any{ + "columns": []any{ + map[string]any{"name": "In progress", "display_order": 0, "color": "#123456", "status_ids": []any{}}, + }, + "card_fields": []any{ + map[string]any{"field_identifier": "title", "field_type": "system", "display_order": 0, "width": 4}, + }, + "view_settings": map[string]any{"enabled_views": []string{"board", "list"}}, + }) + defer put.Body.Close() + AssertStatusCode(t, put, http.StatusOK) + + // The navigation config page sends only view_settings; the columns, + // card fields, and other settings must survive the merge patch. + patch := MakeAuthRequest(t, server, http.MethodPatch, base, map[string]any{ + "view_settings": map[string]any{"enabled_views": []string{"board"}}, + }) + defer patch.Body.Close() + AssertStatusCode(t, patch, http.StatusOK) + + get := MakeAuthRequest(t, server, http.MethodGet, base, nil) + defer get.Body.Close() + AssertStatusCode(t, get, http.StatusOK) + var config struct { + Data struct { + Columns []struct { + Name string `json:"name"` + } `json:"columns"` + CardFields []struct { + FieldIdentifier string `json:"field_identifier"` + } `json:"card_fields"` + ViewSettings struct { + EnabledViews *[]string `json:"enabled_views"` + } `json:"view_settings"` + } `json:"data"` + } + DecodeJSON(t, get, &config) + if len(config.Data.Columns) != 1 || config.Data.Columns[0].Name != "In progress" { + t.Fatalf("columns after patch = %+v, want the saved column preserved", config.Data.Columns) + } + if len(config.Data.CardFields) != 1 || config.Data.CardFields[0].FieldIdentifier != "title" { + t.Fatalf("card_fields after patch = %+v, want them preserved", config.Data.CardFields) + } + if config.Data.ViewSettings.EnabledViews == nil || len(*config.Data.ViewSettings.EnabledViews) != 1 || (*config.Data.ViewSettings.EnabledViews)[0] != "board" { + t.Fatalf("view settings after patch = %+v, want [board]", config.Data.ViewSettings) + } + + // Unknown top-level fields still fail closed under the strict decoder. + unknown := MakeAuthRequest(t, server, http.MethodPatch, base, map[string]any{"bogus": true}) + defer unknown.Body.Close() + AssertStatusCode(t, unknown, http.StatusBadRequest) + }) } diff --git a/tests/channel_email_oauth_secret_test.go b/tests/channel_email_oauth_secret_test.go new file mode 100644 index 0000000..94835d3 --- /dev/null +++ b/tests/channel_email_oauth_secret_test.go @@ -0,0 +1,84 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" +) + +// The stored email OAuth client secret is scrubbed from channel reads, so the +// response must instead advertise that one exists. Without the flag the config +// UI cannot tell "never configured" from "saved but hidden" and blocks the +// Connect Mailbox action. +func TestChannelReadReportsStoredEmailOAuthClientSecret(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + newEmailChannel := func(t *testing.T, name string) int { + t.Helper() + createResponse := MakeAuthRequest(t, server, http.MethodPost, "/channels", map[string]interface{}{ + "name": name, + "type": "email", + "direction": "inbound", + "status": "disabled", + "config": "{}", + }) + defer createResponse.Body.Close() + AssertStatusCode(t, createResponse, http.StatusCreated) + var created map[string]interface{} + DecodeJSON(t, createResponse, &created) + return ExtractIDFromResponse(t, created) + } + + t.Run("secret saved", func(t *testing.T) { + channelID := newEmailChannel(t, "OAuth mailbox") + + updateResponse := MakeAuthRequest(t, server, http.MethodPut, + fmt.Sprintf("/channels/%d/config", channelID), map[string]interface{}{ + "config": map[string]interface{}{ + "email_auth_method": "oauth", + "email_oauth_provider_type": "microsoft", + "email_oauth_client_id": "client-id", + "email_oauth_client_secret": "super-secret", + "email_oauth_tenant_id": "tenant-id", + "email_oauth_mailbox": "support@example.com", + }, + }) + defer updateResponse.Body.Close() + AssertStatusCode(t, updateResponse, http.StatusOK) + + getResponse := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/channels/%d", channelID), nil) + defer getResponse.Body.Close() + AssertStatusCode(t, getResponse, http.StatusOK) + var channel map[string]interface{} + DecodeJSON(t, getResponse, &channel) + + if channel["has_email_oauth_client_secret"] != true { + t.Fatalf("has_email_oauth_client_secret = %v, want true", channel["has_email_oauth_client_secret"]) + } + + configRaw, _ := channel["config"].(string) + var config map[string]interface{} + if err := json.Unmarshal([]byte(configRaw), &config); err != nil { + t.Fatalf("decode returned channel config: %v", err) + } + if _, present := config["email_oauth_client_secret"]; present { + t.Fatalf("channel read exposed the stored client secret: %s", configRaw) + } + }) + + t.Run("no secret saved", func(t *testing.T) { + channelID := newEmailChannel(t, "No OAuth mailbox") + + getResponse := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/channels/%d", channelID), nil) + defer getResponse.Body.Close() + AssertStatusCode(t, getResponse, http.StatusOK) + var channel map[string]interface{} + DecodeJSON(t, getResponse, &channel) + + if channel["has_email_oauth_client_secret"] != nil { + t.Fatalf("has_email_oauth_client_secret = %v, want absent", channel["has_email_oauth_client_secret"]) + } + }) +} diff --git a/tests/channel_intakes_list_test.go b/tests/channel_intakes_list_test.go new file mode 100644 index 0000000..c333830 --- /dev/null +++ b/tests/channel_intakes_list_test.go @@ -0,0 +1,112 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + "testing" +) + +// TestChannelListIncludeIntakes covers the opt-in intake attachment on the +// channel listing that lets the portal settings page resolve connected +// mailboxes in one request instead of one per channel (WI-1921). +func TestChannelListIncludeIntakes(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + workspaceID, _ := CreateTestWorkspace(t, server, "Intake List Workspace", "ILST") + _, portalID := SetupPortalChannel(t, server, workspaceID) + + configSetID := GetDefaultConfigurationSet(t, server) + itemTypes := GetItemTypes(t, server, configSetID) + itemTypeID := RequireItemTypeID(t, itemTypes, "Task") + + linkedMailbox := createMailboxChannel(t, server, "Linked mailbox") + unlinkedMailbox := createMailboxChannel(t, server, "Unlinked mailbox") + + create := MakeAuthRequest(t, server, http.MethodPost, fmt.Sprintf("/channels/%d/intakes", linkedMailbox), map[string]interface{}{ + "folder": "INBOX", + "portal_channel_id": portalID, + "workspace_id": workspaceID, + "item_type_id": itemTypeID, + }) + if create.StatusCode != http.StatusCreated && create.StatusCode != http.StatusOK { + body, _ := io.ReadAll(create.Body) + create.Body.Close() + t.Fatalf("create intake: status=%d body=%s", create.StatusCode, body) + } + create.Body.Close() + + listChannels := func(t *testing.T, token, query string) []map[string]any { + t.Helper() + resp := MakeAuthRequestWithToken(t, server, token, http.MethodGet, "/channels"+query, nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + var channels []map[string]any + DecodeJSON(t, resp, &channels) + return channels + } + indexByID := func(channels []map[string]any) map[int]map[string]any { + byID := make(map[int]map[string]any, len(channels)) + for _, channel := range channels { + byID[int(channel["id"].(float64))] = channel + } + return byID + } + + t.Run("include_intakes attaches intakes and omits empty ones", func(t *testing.T) { + channels := indexByID(listChannels(t, server.SessionCookie, + "?type=email&direction=inbound&include_disabled=true&include_intakes=true")) + + linked := channels[linkedMailbox] + if linked == nil { + t.Fatalf("linked mailbox %d missing from channel list", linkedMailbox) + } + intakes, ok := linked["intakes"].([]any) + if !ok || len(intakes) != 1 { + t.Fatalf("linked mailbox intakes = %v, want 1", linked["intakes"]) + } + first := intakes[0].(map[string]any) + if int(first["portal_channel_id"].(float64)) != portalID || int(first["mailbox_id"].(float64)) != linkedMailbox { + t.Fatalf("intake = %v, want portal %d on mailbox %d", first, portalID, linkedMailbox) + } + + unlinked := channels[unlinkedMailbox] + if unlinked == nil { + t.Fatalf("unlinked mailbox %d missing from channel list", unlinkedMailbox) + } + if _, present := unlinked["intakes"]; present { + t.Fatalf("unlinked mailbox intakes = %v, want the field omitted", unlinked["intakes"]) + } + }) + + t.Run("without the flag the channel list omits intakes", func(t *testing.T) { + channels := listChannels(t, server.SessionCookie, + "?type=email&direction=inbound&include_disabled=true") + for _, channel := range channels { + if _, present := channel["intakes"]; present { + t.Fatalf("channel %v carries intakes without include_intakes", channel["id"]) + } + } + }) + + t.Run("a channel manager sees only managed mailboxes with their intakes", func(t *testing.T) { + managerID, managerUsername, managerPassword := CreateTestUserWithCredentials( + t, server, "intake_list_manager", "intake_list_manager@test.com") + addManager := MakeAuthRequest(t, server, http.MethodPost, fmt.Sprintf("/channels/%d/managers", linkedMailbox), map[string]interface{}{ + "manager_type": "user", + "manager_ids": []int{managerID}, + }) + addManager.Body.Close() + managerToken := CreateBearerTokenForUser(t, server, managerUsername, managerPassword) + + channels := listChannels(t, managerToken, + "?type=email&direction=inbound&include_disabled=true&include_intakes=true") + if len(channels) != 1 || int(channels[0]["id"].(float64)) != linkedMailbox { + t.Fatalf("manager channels = %v, want only mailbox %d", channels, linkedMailbox) + } + if intakes, ok := channels[0]["intakes"].([]any); !ok || len(intakes) != 1 { + t.Fatalf("manager intakes = %v, want 1", channels[0]["intakes"]) + } + }) +} diff --git a/tests/custom_field_delete_guard_test.go b/tests/custom_field_delete_guard_test.go index f7194ab..9300375 100644 --- a/tests/custom_field_delete_guard_test.go +++ b/tests/custom_field_delete_guard_test.go @@ -84,6 +84,39 @@ func TestCustomFieldDeleteGuard(t *testing.T) { AssertStatusCode(t, resp, http.StatusConflict) }) + t.Run("blocks_delete_while_a_portal_customer_json_value_exists", func(t *testing.T) { + fieldID := CreateTestCustomField(t, server, "Guarded Customer Field "+ts(), "text", "") + fieldKey := strconv.Itoa(fieldID) + + if _, err := server.DB().ExecWrite( + `INSERT INTO portal_customers (name, email, custom_field_values) VALUES (?, ?, ?)`, + "Guard customer "+ts(), "guard-customer-"+ts()+"@example.com", + fmt.Sprintf(`{"%s":"in use"}`, fieldKey), + ); err != nil { + t.Fatalf("seed portal customer cfv: %v", err) + } + + resp := MakeAuthRequest(t, server, http.MethodDelete, fmt.Sprintf("/admin/custom-fields/%d", fieldID), nil) + _ = resp.Body.Close() + AssertStatusCode(t, resp, http.StatusConflict) + }) + + t.Run("blocks_delete_while_a_customer_organisation_name_mirror_exists", func(t *testing.T) { + fieldName := "Guarded Org Field " + ts() + fieldID := CreateTestCustomField(t, server, fieldName, "text", "") + + if _, err := server.DB().ExecWrite( + `INSERT INTO customer_organisations (name, custom_field_values) VALUES (?, ?)`, + "Guard org "+ts(), fmt.Sprintf(`{"%s":"in use"}`, fieldName), + ); err != nil { + t.Fatalf("seed customer organisation cfv: %v", err) + } + + resp := MakeAuthRequest(t, server, http.MethodDelete, fmt.Sprintf("/admin/custom-fields/%d", fieldID), nil) + _ = resp.Body.Close() + AssertStatusCode(t, resp, http.StatusConflict) + }) + t.Run("allows_delete_of_an_unused_field", func(t *testing.T) { fieldID := CreateTestCustomField(t, server, "Unused Field "+ts(), "text", "") diff --git a/tests/custom_widget_generate_rate_limit_test.go b/tests/custom_widget_generate_rate_limit_test.go new file mode 100644 index 0000000..bdadfc5 --- /dev/null +++ b/tests/custom_widget_generate_rate_limit_test.go @@ -0,0 +1,54 @@ +package tests + +import ( + "net/http" + "testing" +) + +// TestCustomWidgetGenerateRateLimit pins the dedicated per-user generation +// bucket: 20 requests per hour, independent of the general AI limiter. The +// first 20 requests pass the limiter and fail later in the handler because no +// LLM is configured in tests; only the 21st is rejected with 429. +func TestCustomWidgetGenerateRateLimit(t *testing.T) { + // The black-box harness disables rate limits by default; turn them back on + // for this test before the server reads the environment. + t.Setenv("WINDSHIFT_E2E_DISABLE_RATE_LIMITS", "0") + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + body := map[string]any{"prompt": "show my open items"} + + for i := 0; i < 20; i++ { + resp := MakeAuthRequest(t, server, http.MethodPost, "/custom-widgets/generate", body) + status := resp.StatusCode + limit := resp.Header.Get("X-RateLimit-Limit") + _ = resp.Body.Close() + if status == http.StatusTooManyRequests { + t.Fatalf("request %d was rate limited before the 20/hour burst was exhausted", i+1) + } + if limit != "20" { + t.Fatalf("request %d X-RateLimit-Limit = %q, want 20", i+1, limit) + } + } + + resp := MakeAuthRequest(t, server, http.MethodPost, "/custom-widgets/generate", body) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("21st request status = %d, want 429", resp.StatusCode) + } + if resp.Header.Get("Retry-After") == "" { + t.Fatal("429 missing Retry-After") + } + if got := resp.Header.Get("X-RateLimit-Limit"); got != "20" { + t.Fatalf("429 X-RateLimit-Limit = %q, want 20", got) + } + + var errBody struct { + Code string `json:"code"` + } + DecodeJSON(t, resp, &errBody) + if errBody.Code != "RATE_LIMITED" { + t.Fatalf("429 code = %q, want RATE_LIMITED", errBody.Code) + } +} diff --git a/tests/customer_organisation_settings_test.go b/tests/customer_organisation_settings_test.go new file mode 100644 index 0000000..d4814a4 --- /dev/null +++ b/tests/customer_organisation_settings_test.go @@ -0,0 +1,61 @@ +package tests + +import ( + "net/http" + "strconv" + "testing" +) + +// TestUpdateCustomerOrganisationPreservesSettings pins the partial-update +// contract for the structured settings blob: the Time > Customers form does not +// carry settings, so a metadata save must not wipe the portal request-sharing +// configuration stored there. +func TestUpdateCustomerOrganisationPreservesSettings(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/customer-organisations", map[string]any{ + "name": "Acme", + "active": true, + "settings": map[string]any{ + "request_sharing": "automatic", + "request_sharing_audience": "organisation", + }, + }) + defer createResp.Body.Close() + AssertStatusCode(t, createResp, http.StatusCreated) + + var created struct { + ID int `json:"id"` + Settings map[string]any `json:"settings"` + } + DecodeJSON(t, createResp, &created) + if created.Settings["request_sharing"] != "automatic" { + t.Fatalf("create settings = %#v, want request_sharing automatic", created.Settings) + } + + updateResp := MakeAuthRequest(t, server, http.MethodPut, "/customer-organisations/"+strconv.Itoa(created.ID), map[string]any{ + "name": "Acme Renamed", + "active": true, + }) + defer updateResp.Body.Close() + AssertStatusCode(t, updateResp, http.StatusOK) + + getResp := MakeAuthRequest(t, server, http.MethodGet, "/customer-organisations/"+strconv.Itoa(created.ID), nil) + defer getResp.Body.Close() + AssertStatusCode(t, getResp, http.StatusOK) + var got struct { + Name string `json:"name"` + Settings map[string]any `json:"settings"` + } + DecodeJSON(t, getResp, &got) + if got.Name != "Acme Renamed" { + t.Errorf("name = %q, want Acme Renamed", got.Name) + } + if got.Settings["request_sharing"] != "automatic" { + t.Errorf("settings = %#v, want request_sharing automatic preserved", got.Settings) + } + if got.Settings["request_sharing_audience"] != "organisation" { + t.Errorf("settings = %#v, want request_sharing_audience organisation preserved", got.Settings) + } +} diff --git a/tests/docmost_credentials_test.go b/tests/docmost_credentials_test.go new file mode 100644 index 0000000..15c59b1 --- /dev/null +++ b/tests/docmost_credentials_test.go @@ -0,0 +1,250 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "windshift/internal/utils" +) + +// fakeDocmostServer serves the endpoints the Docmost client uses: the +// authenticated search API and the OAuth register/token endpoints. +func fakeDocmostServer(t *testing.T, validToken string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/oauth/register": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id":"fake-client","token_endpoint_auth_method":"none"}`)) + return + case "/api/oauth/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"oauth-access","refresh_token":"oauth-refresh","token_type":"Bearer","scope":"read write","expires_in":3600}`)) + return + case "/api/search": + default: + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer "+validToken && r.Header.Get("Authorization") != "Bearer oauth-access" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized","statusCode":401}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"p1","slugId":"AbC","title":"Deployment Runbook","highlight":"the deploy steps","rank":0.9,"space":{"name":"Engineering","slug":"engineering"}}]}}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +// allowLocalOutbound permits the loopback Docmost fake and restores the prior +// process-wide policy. +func allowLocalOutbound(t *testing.T) { + t.Helper() + previous := utils.AllowLocalConnections() + utils.SetAllowLocalConnections(true) + t.Cleanup(func() { utils.SetAllowLocalConnections(previous) }) +} + +// docmostTestServer starts an in-process server with an authenticated admin +// session and a workspace. +func docmostTestServer(t *testing.T) (*TestServer, int) { + t.Helper() + allowLocalOutbound(t) + ts, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, ts) + workspaceID, _ := CreateTestWorkspace(t, ts, "Docmost Workspace", shortKey("DOC")) + return ts, workspaceID +} + +// createDocmostProvider registers the Docmost integration provider the way an +// admin would, and returns its id. +func createDocmostProvider(t *testing.T, ts *TestServer, baseURL string) string { + t.Helper() + resp := MakeAuthRequest(t, ts, http.MethodPost, "/admin/integration-providers", map[string]any{ + "slug": "docmost", + "name": "Docmost", + "provider_type": "docmost", + "enabled": true, + "provider_config": fmt.Sprintf(`{"base_url":%q}`, baseURL), + }) + AssertStatusCode(t, resp, http.StatusCreated) + resp.Body.Close() + + listResp := MakeAuthRequest(t, ts, http.MethodGet, "/admin/integration-providers", nil) + AssertStatusCode(t, listResp, http.StatusOK) + defer listResp.Body.Close() + var list []struct { + ID string `json:"id"` + } + DecodeJSON(t, listResp, &list) + for _, p := range list { + if p.ID != "" { + return p.ID + } + } + t.Fatal("docmost provider was not listed after create") + return "" +} + +type docmostSearchBody struct { + Results []struct { + Source string `json:"source"` + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` + } `json:"results"` + Sources []struct { + Source string `json:"source"` + State string `json:"state"` + } `json:"sources"` +} + +// TestDocmostIntegrationFlow drives the shared connected-accounts surface for +// Docmost: admin config, user OAuth connect, search, and disconnect. +func TestDocmostIntegrationFlow(t *testing.T) { + ts, workspaceID := docmostTestServer(t) + fake := fakeDocmostServer(t, "good-token") + providerID := createDocmostProvider(t, ts, fake.URL) + + t.Run("the shared available-providers list includes Docmost", func(t *testing.T) { + resp := MakeAuthRequest(t, ts, http.MethodGet, "/users/me/integration-connections/available", nil) + AssertStatusCode(t, resp, http.StatusOK) + defer resp.Body.Close() + var providers []struct { + ID string `json:"id"` + ProviderType string `json:"provider_type"` + } + DecodeJSON(t, resp, &providers) + found := false + for _, p := range providers { + if p.ID == providerID && p.ProviderType == "docmost" { + found = true + } + } + if !found { + t.Fatalf("docmost not in available providers: %+v", providers) + } + }) + + var state string + t.Run("start returns a Docmost consent URL", func(t *testing.T) { + resp := MakeAuthRequest(t, ts, http.MethodGet, "/integrations/oauth/docmost/start", nil) + AssertStatusCode(t, resp, http.StatusOK) + defer resp.Body.Close() + var body struct { + AuthURL string `json:"auth_url"` + } + DecodeJSON(t, resp, &body) + parsed, err := url.Parse(body.AuthURL) + if err != nil { + t.Fatalf("parse auth_url: %v", err) + } + if parsed.Path != "/oauth/consent" || parsed.Query().Get("client_id") != "fake-client" || parsed.Query().Get("code_challenge_method") != "S256" { + t.Fatalf("auth_url = %q", body.AuthURL) + } + state = parsed.Query().Get("state") + if state == "" { + t.Fatal("auth_url has no state") + } + }) + + t.Run("callback stores the connection", func(t *testing.T) { + callbackURL := ts.APIBase + "/integrations/oauth/docmost/callback?" + url.Values{"state": {state}, "code": {"auth-code"}}.Encode() + req, err := http.NewRequest(http.MethodGet, callbackURL, nil) + if err != nil { + t.Fatalf("build callback: %v", err) + } + req.Header.Set("Cookie", ts.SessionCookie) + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := noRedirect.Do(req) + if err != nil { + t.Fatalf("callback: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound || !strings.Contains(resp.Header.Get("Location"), "oauth=success") { + t.Fatalf("callback status=%d location=%q", resp.StatusCode, resp.Header.Get("Location")) + } + + connResp := MakeAuthRequest(t, ts, http.MethodGet, "/users/me/integration-connections", nil) + AssertStatusCode(t, connResp, http.StatusOK) + defer connResp.Body.Close() + var connections []struct { + IntegrationProviderID string `json:"integration_provider_id"` + } + DecodeJSON(t, connResp, &connections) + found := false + for _, c := range connections { + if c.IntegrationProviderID == providerID { + found = true + } + } + if !found { + t.Fatalf("docmost connection not listed: %+v", connections) + } + }) + + t.Run("knowledge search returns the Docmost hit", func(t *testing.T) { + resp := MakeAuthRequest(t, ts, http.MethodGet, fmt.Sprintf("/workspaces/%d/knowledge/search?q=deploy", workspaceID), nil) + AssertStatusCode(t, resp, http.StatusOK) + defer resp.Body.Close() + var body docmostSearchBody + DecodeJSON(t, resp, &body) + if len(body.Results) != 1 || body.Results[0].Source != "docmost" { + t.Fatalf("results = %+v, want one docmost hit", body.Results) + } + if body.Results[0].URL != fake.URL+"/s/engineering/p/AbC" || body.Results[0].Snippet != "the deploy steps" { + t.Fatalf("result = %+v", body.Results[0]) + } + }) + + t.Run("disconnect removes the connection", func(t *testing.T) { + resp := MakeAuthRequest(t, ts, http.MethodDelete, "/users/me/integration-connections/"+providerID, nil) + AssertStatusCode(t, resp, http.StatusNoContent) + resp.Body.Close() + + searchResp := MakeAuthRequest(t, ts, http.MethodGet, fmt.Sprintf("/workspaces/%d/knowledge/search?q=deploy", workspaceID), nil) + AssertStatusCode(t, searchResp, http.StatusOK) + defer searchResp.Body.Close() + var body docmostSearchBody + DecodeJSON(t, searchResp, &body) + if len(body.Sources) != 1 || body.Sources[0].State != "not_connected" { + t.Fatalf("sources = %+v, want not_connected after disconnect", body.Sources) + } + }) +} + +// TestDocmostProviderRequiresAdmin proves the base connection is admin-only. +func TestDocmostProviderRequiresAdmin(t *testing.T) { + ts, _ := docmostTestServer(t) + fake := fakeDocmostServer(t, "good-token") + + memberID, username, password := CreateTestUserWithCredentials(t, ts, "docmost-nonadmin", "docmost-nonadmin@test.com") + _ = memberID + memberCookie := CreateBearerTokenForUser(t, ts, username, password) + + resp := MakeAuthRequestWithToken(t, ts, memberCookie, http.MethodPost, "/admin/integration-providers", map[string]any{ + "slug": "docmost", + "name": "Docmost", + "provider_type": "docmost", + "enabled": true, + "provider_config": fmt.Sprintf(`{"base_url":%q}`, fake.URL), + }) + if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("non-admin create status = %d, want 403/404 (%s)", resp.StatusCode, body) + } + resp.Body.Close() +} diff --git a/tests/email_outbox_http_test.go b/tests/email_outbox_http_test.go index 64358de..ccf8ca2 100644 --- a/tests/email_outbox_http_test.go +++ b/tests/email_outbox_http_test.go @@ -53,6 +53,37 @@ func createItemWithComments(t *testing.T, server *TestServer, workspaceID int, c return itemID, commentIDs } +// seedOutboxReceipt inserts an intake_confirmation row. Receipts carry no +// comment_id, so they can only be addressed by outbox row id. The production +// API enqueues a receipt only through the inbound-mail pipeline, so this is the +// documented direct-write exception for exercising the operator boundary. +func seedOutboxReceipt(t *testing.T, server *TestServer, channelID, itemID int) int { + t.Helper() + var receiptID int + if err := server.DB().QueryRow(` + INSERT INTO email_reply_outbox ( + kind, comment_id, channel_id, item_id, to_email, to_name, subject, + html_body, text_body, message_id, in_reply_to, references_json, + from_email, from_name, attempt_count, next_attempt_at, created_at, updated_at + ) VALUES ('intake_confirmation', NULL, ?, ?, 'customer@example.com', 'Customer One', 'We received your request', + '

body

', 'body', ?, '', '[]', + 'noreply@test.com', 'Test App', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + `, channelID, itemID, fmt.Sprintf("", itemID)).Scan(&receiptID); err != nil { + t.Fatalf("seed intake receipt: %v", err) + } + return receiptID +} + +func setOutboxReceiptState(t *testing.T, server *TestServer, receiptID int, mutate string) { + t.Helper() + if _, err := server.DB().ExecWrite(fmt.Sprintf( + "UPDATE email_reply_outbox SET %s, updated_at = CURRENT_TIMESTAMP WHERE id = ?", mutate, + ), receiptID); err != nil { + t.Fatalf("set outbox receipt state %d: %v", receiptID, err) + } +} + func setOutboxState(t *testing.T, server *TestServer, commentID int, mutate string) { t.Helper() if _, err := server.DB().ExecWrite(fmt.Sprintf( @@ -231,6 +262,231 @@ func TestEmailReplyOutboxLifecycle(t *testing.T) { }) } +// TestEmailReplyOutboxBulkDiscard verifies the one-action recovery for a burst +// of queued customer replies: the channel's whole pending queue is canceled in +// a single request, delivered and in-flight rows keep their state, and the +// operator boundary still denies users without channel management. +func TestEmailReplyOutboxBulkDiscard(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + channelID := createRateLimitedEmailChannel(t, server, "Outbox bulk discard") + workspaceID := getOutboxWorkspaceID(t, server, channelID) + itemID, commentIDs := createItemWithComments(t, server, workspaceID, 4) + + seedOutboxReplyForComment(t, server, channelID, commentIDs[0], itemID, "Re: pending one") + seedOutboxReplyForComment(t, server, channelID, commentIDs[1], itemID, "Re: pending two") + seedOutboxReplyForComment(t, server, channelID, commentIDs[2], itemID, "Re: delivered") + setOutboxState(t, server, commentIDs[2], "delivered_at = CURRENT_TIMESTAMP") + seedOutboxReplyForComment(t, server, channelID, commentIDs[3], itemID, "Re: in flight") + setOutboxState(t, server, commentIDs[3], fmt.Sprintf( + "lease_owner = 'worker-a', next_attempt_at = '%s'", + time.Now().Add(time.Hour).UTC().Format("2006-01-02 15:04:05"), + )) + receiptID := seedOutboxReceipt(t, server, channelID, CreateTestItem(t, server, workspaceID, "Bulk discard receipt")) + + discardPath := fmt.Sprintf("/channels/%d/email/replies/discard-pending", channelID) + + t.Run("non-manager is denied", func(t *testing.T) { + _, username, password := CreateTestUserWithCredentials(t, server, "outbox_bulk_user", "outbox-bulk-user@test.com") + nonManagerToken := CreateBearerTokenForUser(t, server, username, password) + resp := MakeAuthRequestWithToken(t, server, nonManagerToken, http.MethodPost, discardPath, nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusForbidden) + }) + + t.Run("pending queue is canceled in one request", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath, nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var body struct { + Discarded int `json:"discarded"` + } + DecodeJSON(t, resp, &body) + if body.Discarded != 3 { + t.Fatalf("discarded = %d, want 3 (two replies and the receipt)", body.Discarded) + } + + var pending int + if err := server.DB().QueryRow(` + SELECT COUNT(*) FROM email_reply_outbox + WHERE channel_id = ? AND delivered_at IS NULL AND discarded_at IS NULL + `, channelID).Scan(&pending); err != nil || pending != 1 { + t.Fatalf("pending rows = %d (err %v), want 1 (the in-flight row)", pending, err) + } + + // Delivered and in-flight rows keep their exact state. + var deliveredAt, deliveredDiscarded, inFlightDiscarded, receiptDiscarded *time.Time + var leaseOwner *string + if err := server.DB().QueryRow(`SELECT delivered_at, discarded_at FROM email_reply_outbox WHERE comment_id = ?`, commentIDs[2]).Scan(&deliveredAt, &deliveredDiscarded); err != nil { + t.Fatalf("load delivered row: %v", err) + } + if deliveredAt == nil || deliveredDiscarded != nil { + t.Fatalf("delivered row = delivered %v discarded %v, want delivered and not discarded", deliveredAt, deliveredDiscarded) + } + if err := server.DB().QueryRow(`SELECT lease_owner, discarded_at FROM email_reply_outbox WHERE comment_id = ?`, commentIDs[3]).Scan(&leaseOwner, &inFlightDiscarded); err != nil { + t.Fatalf("load in-flight row: %v", err) + } + if leaseOwner == nil || *leaseOwner != "worker-a" || inFlightDiscarded != nil { + t.Fatalf("in-flight row = lease %v discarded %v, want worker-a lease and not discarded", leaseOwner, inFlightDiscarded) + } + if err := server.DB().QueryRow(`SELECT discarded_at FROM email_reply_outbox WHERE id = ?`, receiptID).Scan(&receiptDiscarded); err != nil || receiptDiscarded == nil { + t.Fatalf("receipt discarded_at = %v (err %v), want set", receiptDiscarded, err) + } + }) + + t.Run("second request is a no-op", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath, nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var body struct { + Discarded int `json:"discarded"` + } + DecodeJSON(t, resp, &body) + if body.Discarded != 0 { + t.Fatalf("second discard = %d, want 0", body.Discarded) + } + }) + + t.Run("listing reports nothing left to discard", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/channels/%d/email/replies?status=all", channelID), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var body struct { + DiscardableTotal int `json:"discardable_total"` + } + DecodeJSON(t, resp, &body) + if body.DiscardableTotal != 0 { + t.Fatalf("discardable_total = %d, want 0", body.DiscardableTotal) + } + }) +} + +// TestEmailReplyOutboxDiscardReceiptByID verifies an operator can cancel a +// stuck intake receipt through its outbox row id: the success path, the +// one-shot refusal, and the delivered/in-flight/unknown conflicts. +func TestEmailReplyOutboxDiscardReceiptByID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + channelID := createRateLimitedEmailChannel(t, server, "Receipt discard") + workspaceID := getOutboxWorkspaceID(t, server, channelID) + + pendingReceipt := seedOutboxReceipt(t, server, channelID, CreateTestItem(t, server, workspaceID, "Receipt pending")) + deliveredReceipt := seedOutboxReceipt(t, server, channelID, CreateTestItem(t, server, workspaceID, "Receipt delivered")) + setOutboxReceiptState(t, server, deliveredReceipt, "delivered_at = CURRENT_TIMESTAMP") + inFlightReceipt := seedOutboxReceipt(t, server, channelID, CreateTestItem(t, server, workspaceID, "Receipt in flight")) + setOutboxReceiptState(t, server, inFlightReceipt, fmt.Sprintf( + "lease_owner = 'worker-a', next_attempt_at = '%s'", + time.Now().Add(time.Hour).UTC().Format("2006-01-02 15:04:05"), + )) + + // The receipt must be visible as a row with an id and no comment_id, which + // is what makes the row-scoped discard addressable at all. + t.Run("listing exposes the receipt row id without a comment id", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/channels/%d/email/replies?status=all", channelID), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var body struct { + Replies []struct { + ID int `json:"id"` + CommentID *int `json:"comment_id"` + } `json:"replies"` + } + DecodeJSON(t, resp, &body) + found := false + for _, reply := range body.Replies { + if reply.ID != pendingReceipt { + continue + } + found = true + if reply.CommentID != nil { + t.Fatalf("receipt comment_id = %d, want omitted", *reply.CommentID) + } + } + if !found { + t.Fatalf("receipt row %d missing from the outbox listing", pendingReceipt) + } + }) + + discardPath := func(replyID int) string { + return fmt.Sprintf("/channels/%d/email/outbox/%d/discard", channelID, replyID) + } + + t.Run("cancel is durable and one-shot", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath(pendingReceipt), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var body struct { + Discarded bool `json:"discarded"` + ReplyID int `json:"reply_id"` + } + DecodeJSON(t, resp, &body) + if !body.Discarded || body.ReplyID != pendingReceipt { + t.Fatalf("discard response = %+v, want discarded receipt %d", body, pendingReceipt) + } + + var discarded int + if err := server.DB().QueryRow( + `SELECT COUNT(*) FROM email_reply_outbox WHERE id = ? AND discarded_at IS NOT NULL`, + pendingReceipt, + ).Scan(&discarded); err != nil || discarded != 1 { + t.Fatalf("discarded receipt rows = %d (err %v), want 1", discarded, err) + } + + repeat := MakeAuthRequest(t, server, http.MethodPost, discardPath(pendingReceipt), nil) + defer repeat.Body.Close() + AssertStatusCode(t, repeat, http.StatusConflict) + }) + + t.Run("delivered receipt refuses cancellation", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath(deliveredReceipt), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusConflict) + + var body struct { + Code string `json:"code"` + } + DecodeJSON(t, resp, &body) + if body.Code != "EMAIL_REPLY_NOT_DISCARDABLE" { + t.Fatalf("error code = %q, want EMAIL_REPLY_NOT_DISCARDABLE", body.Code) + } + }) + + t.Run("in-flight receipt refuses cancellation", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath(inFlightReceipt), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusConflict) + + var body struct { + Code string `json:"code"` + } + DecodeJSON(t, resp, &body) + if body.Code != "EMAIL_REPLY_SENDING" { + t.Fatalf("error code = %q, want EMAIL_REPLY_SENDING", body.Code) + } + }) + + t.Run("unknown receipt id is not discardable", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodPost, discardPath(999999), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusConflict) + }) +} + // TestEmailReplyOutboxRequiresChannelManagement verifies the denial contract // for the reply-queue surface. func TestEmailReplyOutboxRequiresChannelManagement(t *testing.T) { @@ -246,6 +502,7 @@ func TestEmailReplyOutboxRequiresChannelManagement(t *testing.T) { itemID, commentIDs := createItemWithComments(t, server, workspaceID, 1) seedOutboxReplyForComment(t, server, channelID, commentIDs[0], itemID, "Re: denial fixture") deniedCommentID := commentIDs[0] + deniedReceiptID := seedOutboxReceipt(t, server, channelID, CreateTestItem(t, server, workspaceID, "Receipt denial")) _, otherUsername, otherPassword := CreateTestUserWithCredentials( t, server, "outbox_other", "outbox-other@test.com", @@ -260,6 +517,7 @@ func TestEmailReplyOutboxRequiresChannelManagement(t *testing.T) { {"list denied", http.MethodGet, fmt.Sprintf("/channels/%d/email/replies", channelID)}, {"retry denied", http.MethodPost, fmt.Sprintf("/channels/%d/email/replies/%d/retry", channelID, deniedCommentID)}, {"discard denied", http.MethodPost, fmt.Sprintf("/channels/%d/email/replies/%d/discard", channelID, deniedCommentID)}, + {"receipt discard denied", http.MethodPost, fmt.Sprintf("/channels/%d/email/outbox/%d/discard", channelID, deniedReceiptID)}, } { t.Run(tc.name, func(t *testing.T) { resp := MakeAuthRequestWithToken(t, server, otherToken, tc.method, tc.endpoint, nil) @@ -276,6 +534,13 @@ func TestEmailReplyOutboxRequiresChannelManagement(t *testing.T) { ).Scan(&mutated); err != nil || mutated != 0 { t.Fatalf("mutated rows after denial = %d (err %v), want 0", mutated, err) } + var mutatedReceipts int + if err := server.DB().QueryRow( + `SELECT COUNT(*) FROM email_reply_outbox WHERE id = ? AND (discarded_at IS NOT NULL OR delivered_at IS NOT NULL)`, + deniedReceiptID, + ).Scan(&mutatedReceipts); err != nil || mutatedReceipts != 0 { + t.Fatalf("mutated receipts after denial = %d (err %v), want 0", mutatedReceipts, err) + } } // The outbox is per-recipient: one comment can produce several rows. Each row diff --git a/tests/email_template_translation_http_test.go b/tests/email_template_translation_http_test.go new file mode 100644 index 0000000..90064b6 --- /dev/null +++ b/tests/email_template_translation_http_test.go @@ -0,0 +1,158 @@ +package tests + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// TestEmailTemplateTranslationCRUD covers the admin per-locale override +// endpoints: upsert, list, validation, and delete. +func TestEmailTemplateTranslationCRUD(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + type translationDoc struct { + Locale string `json:"locale"` + Subject string `json:"subject"` + HTMLBody string `json:"html_body"` + TextBody string `json:"text_body"` + } + type templateDoc struct { + ID int `json:"id"` + Name string `json:"name"` + Subject string `json:"subject"` + HTMLBody string `json:"html_body"` + TextBody string `json:"text_body"` + Translations []translationDoc `json:"translations"` + } + + list := MakeAuthRequest(t, server, http.MethodGet, "/email-templates", nil) + defer list.Body.Close() + AssertStatusCode(t, list, http.StatusOK) + var templates []templateDoc + DecodeJSON(t, list, &templates) + + var magicLink templateDoc + for _, tpl := range templates { + if tpl.Name == "magic_link" { + magicLink = tpl + break + } + } + if magicLink.ID == 0 { + t.Fatal("magic_link template was not seeded") + } + + translationURL := fmt.Sprintf("/email-templates/%d/translations/de", magicLink.ID) + upsert := MakeAuthRequest(t, server, http.MethodPut, translationURL, map[string]any{ + "subject": "DE Betreff", + "html_body": "

DE Inhalt

", + "text_body": "DE Inhalt", + }) + defer upsert.Body.Close() + AssertStatusCode(t, upsert, http.StatusOK) + + // The list now carries the override. + list2 := MakeAuthRequest(t, server, http.MethodGet, "/email-templates", nil) + defer list2.Body.Close() + AssertStatusCode(t, list2, http.StatusOK) + var templates2 []templateDoc + DecodeJSON(t, list2, &templates2) + found := false + for _, tpl := range templates2 { + if tpl.Name != "magic_link" { + continue + } + for _, tr := range tpl.Translations { + if tr.Locale == "de" && tr.Subject == "DE Betreff" { + found = true + } + } + } + if !found { + t.Fatal("list did not include the de translation") + } + + // Unsupported locale is rejected. + unsupported := MakeAuthRequest(t, server, http.MethodPut, + fmt.Sprintf("/email-templates/%d/translations/xx", magicLink.ID), map[string]any{ + "subject": "x", + "html_body": "

x

", + }) + defer unsupported.Body.Close() + AssertStatusCode(t, unsupported, http.StatusBadRequest) + + // An unrenderable translation is rejected. + broken := MakeAuthRequest(t, server, http.MethodPut, + fmt.Sprintf("/email-templates/%d/translations/fr", magicLink.ID), map[string]any{ + "subject": "broken", + "html_body": "

{{.FirstName

", + }) + defer broken.Body.Close() + AssertStatusCode(t, broken, http.StatusBadRequest) + + // Delete reverts the locale. + del := MakeAuthRequest(t, server, http.MethodDelete, translationURL, nil) + defer del.Body.Close() + AssertStatusCode(t, del, http.StatusOK) + + delAgain := MakeAuthRequest(t, server, http.MethodDelete, translationURL, nil) + defer delAgain.Body.Close() + AssertStatusCode(t, delAgain, http.StatusNotFound) +} + +// TestEmailTemplatePreviewRendersLocale proves the preview honors the locale, +// so an admin sees the override (or localized built-in default) they will ship. +func TestEmailTemplatePreviewRendersLocale(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + type templateDoc struct { + ID int `json:"id"` + Name string `json:"name"` + Subject string `json:"subject"` + HTMLBody string `json:"html_body"` + TextBody string `json:"text_body"` + } + + list := MakeAuthRequest(t, server, http.MethodGet, "/email-templates", nil) + defer list.Body.Close() + AssertStatusCode(t, list, http.StatusOK) + var templates []templateDoc + DecodeJSON(t, list, &templates) + + var magicLink templateDoc + for _, tpl := range templates { + if tpl.Name == "magic_link" { + magicLink = tpl + break + } + } + if magicLink.ID == 0 { + t.Fatal("magic_link template was not seeded") + } + + preview := MakeAuthRequest(t, server, http.MethodPost, "/email-templates/preview", map[string]any{ + "name": "magic_link", + "subject": magicLink.Subject, + "html_body": magicLink.HTMLBody, + "text_body": magicLink.TextBody, + "locale": "de", + }) + defer preview.Body.Close() + AssertStatusCode(t, preview, http.StatusOK) + var rendered struct { + Subject string `json:"subject"` + HTMLBody string `json:"html_body"` + TextBody string `json:"text_body"` + } + DecodeJSON(t, preview, &rendered) + if !strings.Contains(rendered.HTMLBody, `lang="de"`) { + t.Fatalf("preview is not rendered in de:\n%s", rendered.HTMLBody) + } + if !strings.Contains(rendered.HTMLBody, "Bei Ihrem Portal anmelden") { + t.Fatalf("preview did not use the German built-in default:\n%s", rendered.HTMLBody) + } +} diff --git a/tests/gdpr_customer_export_test.go b/tests/gdpr_customer_export_test.go index c68ace9..b74b5ba 100644 --- a/tests/gdpr_customer_export_test.go +++ b/tests/gdpr_customer_export_test.go @@ -79,15 +79,20 @@ func TestGBPDCustomerDataExport(t *testing.T) { var export struct { SchemaVersion int `json:"schema_version"` Customer struct { - ID int `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Roles []string `json:"roles"` + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Roles []string `json:"roles"` + CustomFields []struct { + FieldID int `json:"field_id"` + Name string `json:"name"` + Value any `json:"value"` + } `json:"custom_fields"` } `json:"customer"` RequestedItems []struct { - ID int `json:"id"` - ItemKey string `json:"item_key"` - Title string `json:"title"` + ID int `json:"id"` + ItemKey string `json:"item_key"` + Title string `json:"title"` WorkspaceKey string `json:"workspace_key"` ItemNumber int `json:"item_number"` } `json:"requested_items"` @@ -97,13 +102,13 @@ func TestGBPDCustomerDataExport(t *testing.T) { Content string `json:"content"` } `json:"comments"` ApprovalDecisions []map[string]any `json:"approval_decisions"` - Attachments []struct { + Attachments []struct { ID int `json:"id"` OriginalFilename string `json:"original_filename"` FileSize int64 `json:"file_size"` } `json:"attachments"` EmailTracking []map[string]any `json:"email_tracking"` - Sessions []struct { + Sessions []struct { ID int `json:"id"` IPAddress *string `json:"ip_address"` UserAgent *string `json:"user_agent"` @@ -118,8 +123,13 @@ func TestGBPDCustomerDataExport(t *testing.T) { t.Fatalf("decode export payload: %v", err) } - if export.SchemaVersion != 2 { - t.Errorf("schema_version = %d, want 2", export.SchemaVersion) + if export.SchemaVersion != 3 { + t.Errorf("schema_version = %d, want 3", export.SchemaVersion) + } + // The resolved custom-field list is always present (empty when the customer + // has no values), so a DSAR consumer never has to guess the shape. + if export.Customer.CustomFields == nil { + t.Error("customer.custom_fields is nil, want an array") } // Profile. diff --git a/tests/helpers.go b/tests/helpers.go index 1d71abf..2bae3a7 100644 --- a/tests/helpers.go +++ b/tests/helpers.go @@ -190,6 +190,15 @@ func StartTestServerWithConfig(t *testing.T, dbType string, mutate func(*server. // that still reads env vars directly rather than through config.Load). _ = os.Setenv("SESSION_SECRET", "test-session-secret-for-integration-tests") + // These black-box tests create data in tight loops (hundreds to thousands of + // requests as one principal), so any meaningful request-rate backstop would + // trip them. They do not assert rate limiting; disable it for the process the + // same way the e2e harness does. A test that wants limits can set the var to + // "0" before starting the server. + if os.Getenv("WINDSHIFT_E2E_DISABLE_RATE_LIMITS") == "" { + _ = os.Setenv("WINDSHIFT_E2E_DISABLE_RATE_LIMITS", "1") + } + // Create server configuration for testing cfg := server.Config{ Port: "0", // Use port 0 for OS-assigned free port diff --git a/tests/item_bulk_labels_http_test.go b/tests/item_bulk_labels_http_test.go new file mode 100644 index 0000000..7f3e49a --- /dev/null +++ b/tests/item_bulk_labels_http_test.go @@ -0,0 +1,274 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +type bulkLabelResponse struct { + RequestedCount int `json:"requested_count"` + UpdatedCount int `json:"updated_count"` + UnchangedCount int `json:"unchanged_count"` + FailedCount int `json:"failed_count"` + Failures []struct { + ItemID int `json:"item_id"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"failures"` +} + +func postBulkLabels(t *testing.T, ts *TestServer, body map[string]any) (*http.Response, bulkLabelResponse) { + t.Helper() + resp := MakeAuthRequest(t, ts, http.MethodPost, "/v2/items/bulk-labels", body) + var result bulkLabelResponse + if resp.StatusCode == http.StatusOK { + DecodeJSON(t, resp, &result) + } + return resp, result +} + +func itemLabelIDs(t *testing.T, ts *TestServer, itemID int) []int { + t.Helper() + resp := MakeAuthRequest(t, ts, http.MethodGet, fmt.Sprintf("/v2/items/%d/labels", itemID), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + var labels []struct { + ID int `json:"id"` + } + DecodeJSON(t, resp, &labels) + ids := make([]int, len(labels)) + for i := range labels { + ids[i] = labels[i].ID + } + return ids +} + +func hasLabelID(ids []int, want int) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +// TestItemBulkLabelsHTTP drives the batch label endpoint: add, remove, and set +// apply to every selected item in one request and per-item failures come back +// without aborting the rest. +func TestItemBulkLabelsHTTP(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + workspaceID, _ := CreateTestWorkspace(t, server, "Bulk Labels", shortKey("BLB")) + first := CreateTestItem(t, server, workspaceID, "Bulk labels first") + second := CreateTestItem(t, server, workspaceID, "Bulk labels second") + bug := createLabelFx(t, server, workspaceID, "bulk-bug", "#ef4444") + feature := createLabelFx(t, server, workspaceID, "bulk-feature", "#22c55e") + customer := createLabelFx(t, server, workspaceID, "bulk-customer", "#f59e0b") + + t.Run("add applies to every selected item", func(t *testing.T) { + resp, result := postBulkLabels(t, server, map[string]any{ + "item_ids": []int{first, second}, + "add": []int{bug.ID}, + }) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.RequestedCount != 2 || result.UpdatedCount != 2 || result.FailedCount != 0 { + t.Fatalf("add result = %+v, want 2/2/0", result) + } + for _, id := range []int{first, second} { + if got := itemLabelIDs(t, server, id); len(got) != 1 || got[0] != bug.ID { + t.Fatalf("item %d labels = %v, want [%d]", id, got, bug.ID) + } + } + }) + + t.Run("remove detaches the shared label", func(t *testing.T) { + resp, result := postBulkLabels(t, server, map[string]any{ + "item_ids": []int{first, second}, + "remove": []int{bug.ID}, + }) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.UpdatedCount != 2 || result.FailedCount != 0 { + t.Fatalf("remove result = %+v, want 2 updated", result) + } + for _, id := range []int{first, second} { + if got := itemLabelIDs(t, server, id); len(got) != 0 { + t.Fatalf("item %d labels = %v, want none", id, got) + } + } + }) + + t.Run("set replaces the full label set", func(t *testing.T) { + setItemLabels(t, server, first, []int{bug.ID}) + setItemLabels(t, server, second, []int{bug.ID, feature.ID}) + resp, result := postBulkLabels(t, server, map[string]any{ + "item_ids": []int{first, second}, + "set": []int{feature.ID, customer.ID}, + }) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.UpdatedCount != 2 || result.FailedCount != 0 { + t.Fatalf("set result = %+v, want 2 updated", result) + } + for _, id := range []int{first, second} { + got := itemLabelIDs(t, server, id) + if len(got) != 2 || !hasLabelID(got, feature.ID) || !hasLabelID(got, customer.ID) { + t.Fatalf("item %d labels = %v, want feature+customer", id, got) + } + } + }) + + t.Run("unchanged items are reported separately", func(t *testing.T) { + resp, result := postBulkLabels(t, server, map[string]any{ + "item_ids": []int{first, second}, + "add": []int{feature.ID}, + }) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.UpdatedCount != 0 || result.UnchangedCount != 2 || result.FailedCount != 0 { + t.Fatalf("no-op result = %+v, want 0 updated / 2 unchanged", result) + } + }) + + t.Run("missing item is a per-item failure", func(t *testing.T) { + resp, result := postBulkLabels(t, server, map[string]any{ + "item_ids": []int{first, 99999999}, + "add": []int{bug.ID}, + }) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.UpdatedCount != 1 || result.FailedCount != 1 || len(result.Failures) != 1 { + t.Fatalf("partial result = %+v, want one update and one failure", result) + } + if result.Failures[0].ItemID != 99999999 || result.Failures[0].Code != "not_found" { + t.Fatalf("failure = %+v, want not_found for the missing item", result.Failures[0]) + } + if got := itemLabelIDs(t, server, first); !hasLabelID(got, bug.ID) { + t.Fatalf("existing item labels = %v, want the added label", got) + } + }) +} + +// TestItemBulkLabelsHTTPLargeSelection labels a whole multi-select batch in a +// single request, proving the endpoint does not degrade into a per-item loop +// as the selection grows. +func TestItemBulkLabelsHTTPLargeSelection(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + workspaceID, _ := CreateTestWorkspace(t, server, "Bulk Labels Large", shortKey("BLG")) + label := createLabelFx(t, server, workspaceID, "bulk-large", "#0ea5e9") + + const count = 40 + ids := make([]int, count) + for i := range ids { + ids[i] = CreateTestItem(t, server, workspaceID, fmt.Sprintf("Bulk large item %d", i)) + } + + resp, result := postBulkLabels(t, server, map[string]any{"item_ids": ids, "add": []int{label.ID}}) + AssertStatusCode(t, resp, http.StatusOK) + resp.Body.Close() + if result.RequestedCount != count || result.UpdatedCount != count || result.FailedCount != 0 { + t.Fatalf("large selection result = %+v, want %d/%d/0", result, count, count) + } + for _, id := range []int{ids[0], ids[count-1]} { + if got := itemLabelIDs(t, server, id); len(got) != 1 || got[0] != label.ID { + t.Fatalf("item %d labels = %v, want [%d]", id, got, label.ID) + } + } +} + +// TestItemBulkLabelsHTTPValidation asserts the request-level rejections: an +// unknown label, an empty operation, a set mixed with a delta, and the item +// cap all fail before any item is touched. +func TestItemBulkLabelsHTTPValidation(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + workspaceID, _ := CreateTestWorkspace(t, server, "Bulk Labels Validation", shortKey("BLV")) + item := CreateTestItem(t, server, workspaceID, "Bulk labels validation item") + label := createLabelFx(t, server, workspaceID, "bulk-validation", "#2563eb") + + cases := []struct { + name string + body map[string]any + want int + }{ + { + name: "unknown label", + body: map[string]any{"item_ids": []int{item}, "add": []int{99999999}}, + want: http.StatusNotFound, + }, + { + name: "no operation", + body: map[string]any{"item_ids": []int{item}}, + want: http.StatusBadRequest, + }, + { + name: "set combined with add", + body: map[string]any{"item_ids": []int{item}, "set": []int{label.ID}, "add": []int{label.ID}}, + want: http.StatusBadRequest, + }, + { + name: "no items", + body: map[string]any{"item_ids": []int{}, "add": []int{label.ID}}, + want: http.StatusBadRequest, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, _ := postBulkLabels(t, server, tc.body) + defer resp.Body.Close() + AssertStatusCode(t, resp, tc.want) + }) + } + + t.Run("item cap", func(t *testing.T) { + ids := make([]int, 501) + for i := range ids { + ids[i] = i + 1 + } + resp, _ := postBulkLabels(t, server, map[string]any{"item_ids": ids, "add": []int{label.ID}}) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusBadRequest) + }) + + if got := itemLabelIDs(t, server, item); len(got) != 0 { + t.Fatalf("rejected requests changed labels: %v", got) + } +} + +// TestItemBulkLabelsHTTPForbidden asserts the denial contract: a viewer who +// can see a locked-down workspace still cannot label its items, and the +// denial is reported per item without a 5xx. +func TestItemBulkLabelsHTTPForbidden(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + adminToken := CreateBearerToken(t, server) + server.BearerToken = adminToken + workspaceID, _ := CreateTestWorkspace(t, server, "Bulk Labels Locked", shortKey("BLL")) + LockDownWorkspace(t, server, workspaceID) + item := CreateTestItem(t, server, workspaceID, "Locked bulk labels item") + label := createLabelFx(t, server, workspaceID, "bulk-locked", "#dc2626") + + viewerID, viewerUsername, viewerPassword := CreateTestUserWithCredentials(t, server, "bulk_label_viewer", "bulk_label_viewer@test.com") + AssignWorkspaceRole(t, server, viewerID, workspaceID, "Viewer") + viewerToken := CreateBearerTokenForUser(t, server, viewerUsername, viewerPassword) + + resp := MakeAuthRequestWithToken(t, server, viewerToken, http.MethodPost, "/v2/items/bulk-labels", map[string]any{ + "item_ids": []int{item}, + "add": []int{label.ID}, + }) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + var result bulkLabelResponse + DecodeJSON(t, resp, &result) + if result.UpdatedCount != 0 || result.FailedCount != 1 || len(result.Failures) != 1 { + t.Fatalf("viewer result = %+v, want one forbidden failure", result) + } + if result.Failures[0].ItemID != item || result.Failures[0].Code != "forbidden" { + t.Fatalf("failure = %+v, want forbidden for item %d", result.Failures[0], item) + } + if got := itemLabelIDs(t, server, item); len(got) != 0 { + t.Fatalf("denied request changed labels: %v", got) + } +} diff --git a/tests/item_hierarchy_read_test.go b/tests/item_hierarchy_read_test.go new file mode 100644 index 0000000..d33f8b9 --- /dev/null +++ b/tests/item_hierarchy_read_test.go @@ -0,0 +1,321 @@ +package tests + +import ( + "fmt" + "net/http" + "net/url" + "testing" +) + +func decodeV2ItemList(t *testing.T, resp *http.Response) []map[string]interface{} { + t.Helper() + var document struct { + Data []map[string]interface{} `json:"data"` + } + DecodeJSON(t, resp, &document) + return document.Data +} + +// TestItemListIncludeHasChildren verifies the opt-in has_children projection: +// a parent reports true, a leaf omits the field, and the default payload does +// not compute it at all. +func TestItemListIncludeHasChildren(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "HasChildren WS", shortKey("HCWS")) + configSetID := GetDefaultConfigurationSet(t, server) + itemTypes := GetItemTypes(t, server, configSetID) + + parent := createHierarchicalItem(t, server, wsID, "Parent Epic", itemTypes["Epic"], 0) + child := createHierarchicalItem(t, server, wsID, "Child Story", itemTypes["Story"], parent) + + resp := MakeAuthRequest(t, server, http.MethodGet, + fmt.Sprintf("/v2/items?workspace_id=%d&include_has_children=true", wsID), nil) + items := decodeV2ItemList(t, resp) + resp.Body.Close() + + byID := make(map[int]map[string]interface{}, len(items)) + for _, item := range items { + byID[intField(item, "id")] = item + } + if len(byID) != 2 { + t.Fatalf("want 2 items, got %d", len(byID)) + } + if got := byID[parent]["has_children"]; got != true { + t.Fatalf("parent has_children = %v, want true", got) + } + if _, present := byID[child]["has_children"]; present { + t.Fatalf("leaf must omit has_children, got %v", byID[child]["has_children"]) + } + + resp = MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/v2/items?workspace_id=%d", wsID), nil) + items = decodeV2ItemList(t, resp) + resp.Body.Close() + for _, item := range items { + if _, present := item["has_children"]; present { + t.Fatalf("has_children must be opt-in, got %v on item %v", item["has_children"], item["id"]) + } + } +} + +// TestItemLevelCountsEndpoint verifies the per-level count read matches the +// filtered item set. +func TestItemLevelCountsEndpoint(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "Level Counts WS", shortKey("LCWS")) + configSetID := GetDefaultConfigurationSet(t, server) + itemTypes := GetItemTypes(t, server, configSetID) + + epic := createHierarchicalItem(t, server, wsID, "Level Epic", itemTypes["Epic"], 0) + storyA := createHierarchicalItem(t, server, wsID, "Level Story A", itemTypes["Story"], epic) + createHierarchicalItem(t, server, wsID, "Level Story B", itemTypes["Story"], epic) + createHierarchicalItem(t, server, wsID, "Level Task", itemTypes["Task"], storyA) + + resp := MakeAuthRequest(t, server, http.MethodGet, + fmt.Sprintf("/v2/items/level-counts?workspace_id=%d", wsID), nil) + AssertStatusCode(t, resp, http.StatusOK) + var document struct { + Data []map[string]interface{} `json:"data"` + } + DecodeJSON(t, resp, &document) + resp.Body.Close() + + counts := make(map[int]int, len(document.Data)) + names := make(map[int]string, len(document.Data)) + for _, row := range document.Data { + counts[intField(row, "level")] = intField(row, "total") + if name, ok := row["name"].(string); ok { + names[intField(row, "level")] = name + } + } + if counts[1] != 1 || counts[2] != 2 || counts[3] != 1 { + t.Fatalf("level counts = %v, want Epic=1 Story=2 Task=1", counts) + } + if names[1] != "Epic" || names[2] != "Story" { + t.Fatalf("level names = %v, want Epic/Story", names) + } + + filtered := url.QueryEscape(`title = "Level Story A"`) + resp = MakeAuthRequest(t, server, http.MethodGet, + fmt.Sprintf("/v2/items/level-counts?workspace_id=%d&sub_ql=%s", wsID, filtered), nil) + AssertStatusCode(t, resp, http.StatusOK) + document.Data = nil + DecodeJSON(t, resp, &document) + resp.Body.Close() + counts = make(map[int]int, len(document.Data)) + for _, row := range document.Data { + counts[intField(row, "level")] = intField(row, "total") + } + if counts[2] != 1 { + t.Fatalf("filtered level counts = %v, want only Story=1", counts) + } + + // A completion filter (status_categories) must resolve in the count query. + completionFilter := url.QueryEscape("status_completed = false") + resp = MakeAuthRequest(t, server, http.MethodGet, + fmt.Sprintf("/v2/items/level-counts?workspace_id=%d&sub_ql=%s", wsID, completionFilter), nil) + AssertStatusCode(t, resp, http.StatusOK) + document.Data = nil + DecodeJSON(t, resp, &document) + resp.Body.Close() + counts = make(map[int]int, len(document.Data)) + for _, row := range document.Data { + counts[intField(row, "level")] = intField(row, "total") + } + if counts[1] != 1 || counts[2] != 2 || counts[3] != 1 { + t.Fatalf("completion-filtered counts = %v, want Epic=1 Story=2 Task=1", counts) + } +} + +// TestItemHierarchyEndpoint exercises the ancestor-scoped descendant read: +// ancestry across levels, breadth-first truncation, per-root cap, depth guard, +// and flattening a matching descendant under a filtered-out intermediate. +func TestItemHierarchyEndpoint(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "Hierarchy Read WS", shortKey("HRWS")) + configSetID := GetDefaultConfigurationSet(t, server) + itemTypes := GetItemTypes(t, server, configSetID) + + epic := createHierarchicalItem(t, server, wsID, "Hierarchy Epic", itemTypes["Epic"], 0) + storyA := createHierarchicalItem(t, server, wsID, "Hierarchy Story A", itemTypes["Story"], epic) + storyB := createHierarchicalItem(t, server, wsID, "Hierarchy Story B", itemTypes["Story"], epic) + task := createHierarchicalItem(t, server, wsID, "Hierarchy Task", itemTypes["Task"], storyA) + subtask := createHierarchicalItem(t, server, wsID, "Hierarchy Sub-task", itemTypes["Sub-task"], task) + + post := func(body map[string]any) []map[string]interface{} { + t.Helper() + resp := MakeAuthRequest(t, server, http.MethodPost, "/v2/items/hierarchy", body) + AssertStatusCode(t, resp, http.StatusOK) + var document struct { + Data []map[string]interface{} `json:"data"` + } + DecodeJSON(t, resp, &document) + resp.Body.Close() + return document.Data + } + + groups := post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 0, "per_root_limit": 100, + }) + if len(groups) != 1 { + t.Fatalf("want 1 root group, got %d", len(groups)) + } + + // The browser client sends explicit nulls for the unset scope fields; the + // decoder must accept that shape. + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "collection_id": nil, + "sub_ql": nil, "ql": nil, "max_depth": 0, "per_root_limit": 100, "per_root_offset": 0, + }) + if len(groups) != 1 { + t.Fatalf("null-scope shape: want 1 root group, got %d", len(groups)) + } + group := groups[0] + if intField(group, "root_id") != epic { + t.Fatalf("root_id = %v, want %d", group["root_id"], epic) + } + if total := intField(group, "total"); total != 4 { + t.Fatalf("total = %d, want 4", total) + } + if group["has_more"] != false { + t.Fatalf("has_more = %v, want false", group["has_more"]) + } + depthByID := map[int]int{} + for _, item := range toAncestorMaps(group["items"]) { + depthByID[intField(item, "id")] = intField(item, "depth") + } + if depthByID[storyA] != 1 || depthByID[storyB] != 1 || depthByID[task] != 2 || depthByID[subtask] != 3 { + t.Fatalf("depths = %v, want story=1 task=2 sub-task=3", depthByID) + } + + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 1, "per_root_limit": 100, + }) + group = groups[0] + if total := intField(group, "total"); total != 2 { + t.Fatalf("max_depth=1 total = %d, want 2", total) + } + for _, item := range toAncestorMaps(group["items"]) { + if intField(item, "depth") != 1 { + t.Fatalf("max_depth=1 returned depth %v", item["depth"]) + } + } + + // Breadth-first truncation keeps shallow levels whole: a cap of 2 returns + // both stories and no task, so no child is orphaned. + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 0, "per_root_limit": 2, + }) + group = groups[0] + if group["has_more"] != true { + t.Fatalf("capped group must report has_more, got %v", group["has_more"]) + } + if total := intField(group, "total"); total != 4 { + t.Fatalf("capped total = %d, want 4", total) + } + capped := toAncestorMaps(group["items"]) + if len(capped) != 2 { + t.Fatalf("capped items = %d, want 2", len(capped)) + } + for _, item := range capped { + if intField(item, "depth") != 1 { + t.Fatalf("breadth-first cap returned depth %v, want 1", item["depth"]) + } + } + + // Per-root offset pages within a root: offset 2 skips the two stories and + // returns the deeper rows, so a per-root "Load more" can continue. + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 0, + "per_root_limit": 2, "per_root_offset": 2, + }) + group = groups[0] + offsetItems := toAncestorMaps(group["items"]) + if len(offsetItems) != 2 { + t.Fatalf("offset items = %d, want 2", len(offsetItems)) + } + for _, item := range offsetItems { + if intField(item, "depth") < 2 { + t.Fatalf("offset skipped shallow rows, got depth %v", item["depth"]) + } + } + + // A completion filter references status_categories; the ranked CTE must + // carry the same joins the list query does. + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 0, "per_root_limit": 100, + "sub_ql": "status_completed = false", + }) + if total := intField(groups[0], "total"); total != 4 { + t.Fatalf("status_completed filter total = %d, want 4", total) + } + + // A QL that excludes the intermediate story still returns the matching task + // flattened under the root. + groups = post(map[string]any{ + "root_ids": []int{epic}, "workspace_id": wsID, "max_depth": 0, "per_root_limit": 100, + "sub_ql": `title = "Hierarchy Task"`, + }) + group = groups[0] + matched := toAncestorMaps(group["items"]) + if len(matched) != 1 { + t.Fatalf("filtered items = %d, want 1", len(matched)) + } + if id := intField(matched[0], "id"); id != task { + t.Fatalf("filtered item id = %d, want task %d", id, task) + } + if depth := intField(matched[0], "depth"); depth != 2 { + t.Fatalf("flattened task depth = %d, want 2", depth) + } +} + +// TestItemHierarchyEndpointPermissionFiltering verifies a root in a workspace +// the caller cannot view yields no descendants (no cross-workspace leak). +func TestItemHierarchyEndpointPermissionFiltering(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + adminToken := CreateBearerToken(t, server) + server.BearerToken = adminToken + + wsA, _ := CreateTestWorkspace(t, server, "Hierarchy Perm A", shortKey("HPA")) + wsB, _ := CreateTestWorkspace(t, server, "Hierarchy Perm B", shortKey("HPB")) + LockDownWorkspace(t, server, wsA) + LockDownWorkspace(t, server, wsB) + + configSetID := GetDefaultConfigurationSet(t, server) + itemTypes := GetItemTypes(t, server, configSetID) + parent := createHierarchicalItem(t, server, wsA, "Visible Parent", itemTypes["Epic"], 0) + child := createHierarchicalItem(t, server, wsB, "Hidden Child", itemTypes["Story"], parent) + + userID, username, password := CreateTestUserWithCredentials(t, server, "hier_read_perm", "hier_read_perm@test.com") + AssignWorkspaceRole(t, server, userID, wsA, "Editor") + userToken := createTokenWithScopesAsUser(t, server, username, password, []string{"items:read"}) + + resp := MakeBearerRequestWithToken(t, server, userToken, http.MethodPost, "/rest/api/v2/items/hierarchy", + map[string]any{"root_ids": []int{parent}, "workspace_id": wsA, "max_depth": 0, "per_root_limit": 100}) + AssertStatusCode(t, resp, http.StatusOK) + var document struct { + Data []map[string]interface{} `json:"data"` + } + DecodeJSON(t, resp, &document) + resp.Body.Close() + + if len(document.Data) != 1 { + t.Fatalf("want 1 group, got %d", len(document.Data)) + } + for _, item := range toAncestorMaps(document.Data[0]["items"]) { + if intField(item, "id") == child { + t.Fatalf("hierarchy read leaked hidden child %d", child) + } + } + if total := intField(document.Data[0], "total"); total != 0 { + t.Fatalf("visible parent total = %d, want 0", total) + } +} diff --git a/tests/matrix_route_enumerator.go b/tests/matrix_route_enumerator.go index a8e6961..2724ba9 100644 --- a/tests/matrix_route_enumerator.go +++ b/tests/matrix_route_enumerator.go @@ -34,10 +34,9 @@ type RegisteredRoute struct { // /scim/v2 (the only such group // server.go declares today) // -// Direct mux.Handle("METHOD /path", ...) registrations in server.go (the -// logbook/LLM proxies) are intentionally excluded — they bypass the -// middleware chain and are mostly internal. Add them if a test surfaces a -// concrete need. +// Direct mux.Handle("METHOD /path", ...) registrations in server.go (internal +// endpoints) are intentionally excluded — they bypass the middleware chain and +// are mostly internal. Add them if a test surfaces a concrete need. // // The function fails the test if it cannot locate the routes/ directory // from any of the candidate roots — silent zero-result is too easy to diff --git a/tests/matrix_routes.go b/tests/matrix_routes.go index bcfeeb1..5774351 100644 --- a/tests/matrix_routes.go +++ b/tests/matrix_routes.go @@ -164,6 +164,7 @@ var MatrixRoutes = []MatrixRoute{ {Method: "POST", Path: "/api/items/{id}/scm-links/create-branch", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/bulk-update", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/bulk-patch", Class: "workspace.item.edit"}, + {Method: "POST", Path: "/api/items/bulk-labels", Class: "workspace.item.edit"}, {Method: "POST", Path: "/api/items/roadmap-hierarchy-dates", Class: "workspace.item.view"}, // --- /api/attachments — gate on the parent item's view/edit perm --- diff --git a/tests/notifications_clear_test.go b/tests/notifications_clear_test.go index 99fb57b..aeb59d2 100644 --- a/tests/notifications_clear_test.go +++ b/tests/notifications_clear_test.go @@ -1,6 +1,7 @@ package tests import ( + "fmt" "net/http" "testing" @@ -40,3 +41,41 @@ func TestClearNotificationsPersistsAcrossReload(t *testing.T) { t.Fatalf("notifications after clear and reload = %d, want 0", len(notifications)) } } + +func TestDeleteNotificationPersistsAcrossReload(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + created := MakeAuthRequest(t, server, http.MethodPost, "/notifications", map[string]any{ + "title": "Dismiss me", + "message": "Persistent notification", + "type": "info", + }) + AssertStatusCode(t, created, http.StatusCreated) + var dismissed models.Notification + DecodeJSON(t, created, &dismissed) + created.Body.Close() + + kept := MakeAuthRequest(t, server, http.MethodPost, "/notifications", map[string]any{ + "title": "Keep me", + "message": "Persistent notification", + "type": "info", + }) + AssertStatusCode(t, kept, http.StatusCreated) + kept.Body.Close() + + deleteResponse := MakeAuthRequest(t, server, http.MethodDelete, fmt.Sprintf("/notifications/%d", dismissed.ID), nil) + AssertStatusCode(t, deleteResponse, http.StatusNoContent) + deleteResponse.Body.Close() + + reloadResponse := MakeAuthRequest(t, server, http.MethodGet, "/notifications", nil) + defer reloadResponse.Body.Close() + AssertStatusCode(t, reloadResponse, http.StatusOK) + + var notifications []models.Notification + DecodeJSON(t, reloadResponse, ¬ifications) + if len(notifications) != 1 || notifications[0].Title != "Keep me" { + t.Fatalf("notifications after dismiss and reload = %+v, want only the kept notification", notifications) + } +} diff --git a/tests/page_summaries_http_test.go b/tests/page_summaries_http_test.go new file mode 100644 index 0000000..9683dcd --- /dev/null +++ b/tests/page_summaries_http_test.go @@ -0,0 +1,150 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + "testing" +) + +type pageSummaryRow struct { + WorkspaceID int `json:"workspace_id"` + PageID int `json:"page_id"` + ParentID *int `json:"parent_id"` + Title string `json:"title"` + Depth int `json:"depth"` + IsHome bool `json:"is_home"` + UpdatedAt string `json:"updated_at"` +} + +func createPageViaAPI(t *testing.T, server *TestServer, workspaceID int, body map[string]any) int { + t.Helper() + resp := MakeAuthRequest(t, server, http.MethodPost, fmt.Sprintf("/v2/workspaces/%d/pages", workspaceID), body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("create page: %d - %s", resp.StatusCode, string(raw)) + } + var page struct { + ID int `json:"id"` + } + DecodeJSON(t, resp, &page) + if page.ID == 0 { + t.Fatal("created page has no id") + } + return page.ID +} + +func fetchPageSummaries(t *testing.T, server *TestServer, query string) (int, []pageSummaryRow) { + t.Helper() + resp := MakeAuthRequest(t, server, http.MethodGet, "/v2/pages/summaries"+query, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil + } + var rows []pageSummaryRow + DecodeJSON(t, resp, &rows) + return resp.StatusCode, rows +} + +// TestPageSummariesAcrossWorkspacesHTTP covers the cross-workspace list/search +// endpoint that lets the mobile pages surface batch workspace ids instead of +// one request per workspace (WI-1918). +func TestPageSummariesAcrossWorkspacesHTTP(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsA, _ := CreateTestWorkspace(t, server, "Summaries A", "SUMA") + wsB, _ := CreateTestWorkspace(t, server, "Summaries B", "SUMB") + + home := createPageViaAPI(t, server, wsA, map[string]any{"title": "Home", "content": "root body", "is_home": true}) + child := createPageViaAPI(t, server, wsA, map[string]any{"title": "Child", "content": "child body with needles", "parent_id": home}) + beta := createPageViaAPI(t, server, wsB, map[string]any{"title": "Beta", "content": "beta body"}) + + t.Run("lists every visible page across workspaces", func(t *testing.T) { + status, rows := fetchPageSummaries(t, server, fmt.Sprintf("?workspace_ids=%d,%d", wsA, wsB)) + if status != http.StatusOK { + t.Fatalf("status = %d", status) + } + got := make(map[int]pageSummaryRow, len(rows)) + for _, row := range rows { + got[row.PageID] = row + } + if len(got) != 3 { + t.Fatalf("got %d rows, want 3: %+v", len(got), rows) + } + if row := got[home]; row.WorkspaceID != wsA || row.Title != "Home" || !row.IsHome || row.Depth != 0 || row.UpdatedAt == "" { + t.Fatalf("home row = %+v", row) + } + if row := got[child]; row.WorkspaceID != wsA || row.Depth != 1 || row.ParentID == nil || *row.ParentID != home { + t.Fatalf("child row = %+v", row) + } + if row := got[beta]; row.WorkspaceID != wsB || row.Title != "Beta" { + t.Fatalf("beta row = %+v", row) + } + }) + + t.Run("query searches titles and bodies", func(t *testing.T) { + status, rows := fetchPageSummaries(t, server, fmt.Sprintf("?workspace_ids=%d,%d&q=needles", wsA, wsB)) + if status != http.StatusOK { + t.Fatalf("status = %d", status) + } + if len(rows) != 1 || rows[0].PageID != child { + t.Fatalf("search rows = %+v, want only the child page", rows) + } + }) + + t.Run("limit caps search results", func(t *testing.T) { + status, rows := fetchPageSummaries(t, server, fmt.Sprintf("?workspace_ids=%d,%d&q=body&limit=1", wsA, wsB)) + if status != http.StatusOK { + t.Fatalf("status = %d", status) + } + if len(rows) != 1 { + t.Fatalf("capped rows = %+v, want 1", rows) + } + }) + + t.Run("empty workspace list returns no rows", func(t *testing.T) { + status, rows := fetchPageSummaries(t, server, "") + if status != http.StatusOK || len(rows) != 0 { + t.Fatalf("status = %d rows = %+v, want 200/empty", status, rows) + } + }) + + t.Run("invalid limit is rejected", func(t *testing.T) { + resp := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/v2/pages/summaries?workspace_ids=%d&limit=0", wsA), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusBadRequest) + }) +} + +// TestPageSummariesAcrossWorkspacesSkipsInaccessibleWorkspaces asserts the +// endpoint contributes no rows for a workspace the caller cannot see while +// still returning the workspaces it can. +func TestPageSummariesAcrossWorkspacesSkipsInaccessibleWorkspaces(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsA, _ := CreateTestWorkspace(t, server, "Summaries Allowed", "SMA") + wsB, _ := CreateTestWorkspace(t, server, "Summaries Denied", "SMD") + LockDownWorkspace(t, server, wsA) + LockDownWorkspace(t, server, wsB) + + createPageViaAPI(t, server, wsA, map[string]any{"title": "Allowed page", "content": "visible"}) + createPageViaAPI(t, server, wsB, map[string]any{"title": "Denied page", "content": "hidden"}) + + viewerID, viewerUsername, viewerPassword := CreateTestUserWithCredentials(t, server, "page_summary_viewer", "page_summary_viewer@test.com") + AssignWorkspaceRole(t, server, viewerID, wsA, "Viewer") + viewerToken := CreateBearerTokenForUser(t, server, viewerUsername, viewerPassword) + + resp := MakeAuthRequestWithToken(t, server, viewerToken, http.MethodGet, fmt.Sprintf("/v2/pages/summaries?workspace_ids=%d,%d", wsA, wsB), nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + var rows []pageSummaryRow + DecodeJSON(t, resp, &rows) + if len(rows) != 1 || rows[0].WorkspaceID != wsA || rows[0].Title != "Allowed page" { + t.Fatalf("rows = %+v, want only the allowed workspace page", rows) + } +} diff --git a/tests/portal_customer_cfv_keys_test.go b/tests/portal_customer_cfv_keys_test.go new file mode 100644 index 0000000..e8dab11 --- /dev/null +++ b/tests/portal_customer_cfv_keys_test.go @@ -0,0 +1,170 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" +) + +// TestPortalCustomerCustomFieldValueKeys pins the WI-2025 contract on the +// session API: custom-field values are stored and returned by numeric field id, +// a legacy name key is accepted on write and kept as a rollback mirror, and an +// ambiguous duplicate name is rejected. +func TestPortalCustomerCustomFieldValueKeys(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + fieldName := "CFV Key Field " + ts() + fieldID := createCustomerCustomField(t, server, map[string]interface{}{ + "name": fieldName, + "field_type": "text", + "applies_to_portal_customers": true, + }) + fieldKey := strconv.Itoa(fieldID) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/portal-customers", map[string]interface{}{ + "name": "CFV Customer " + ts(), + "email": "cfv-customer-" + ts() + "@example.com", + "custom_field_values": map[string]interface{}{fieldName: "gold"}, + }) + AssertStatusCode(t, createResp, http.StatusCreated) + var created map[string]interface{} + DecodeJSON(t, createResp, &created) + customerID := ExtractIDFromResponse(t, created) + + // API response is id-keyed; the unique-name mirror is stripped. + cfv, _ := created["custom_field_values"].(map[string]interface{}) + if cfv[fieldKey] != "gold" { + t.Fatalf("response cfv[%s] = %v, want gold (cfv=%v)", fieldKey, cfv[fieldKey], cfv) + } + if _, ok := cfv[fieldName]; ok { + t.Fatalf("response must strip the unique-name mirror, got %v", cfv) + } + + // Stored row keeps the mirror for downgrade reads. + stored := readStoredCFV(t, server, "portal_customers", customerID) + if stored[fieldKey] != "gold" || stored[fieldName] != "gold" { + t.Fatalf("stored cfv = %v, want id key %s and name mirror %q", stored, fieldKey, fieldName) + } + + // A legacy name-keyed row (seeded directly, as a pre-0.9.4 install would + // have) is returned id-keyed. + legacyName := "CFV Legacy Field " + ts() + legacyID := createCustomerCustomField(t, server, map[string]interface{}{ + "name": legacyName, + "field_type": "text", + "applies_to_portal_customers": true, + }) + legacyEmail := "cfv-legacy-" + ts() + "@example.com" + if _, err := server.DB().ExecWrite( + `INSERT INTO portal_customers (name, email, custom_field_values) VALUES (?, ?, ?)`, + "CFV Legacy "+ts(), legacyEmail, fmt.Sprintf(`{"%s":"silver"}`, legacyName), + ); err != nil { + t.Fatalf("seed legacy name-keyed customer: %v", err) + } + var legacyCustomerID int + if err := server.DB().QueryRow(`SELECT id FROM portal_customers WHERE email = ?`, legacyEmail).Scan(&legacyCustomerID); err != nil { + t.Fatalf("resolve legacy customer: %v", err) + } + getResp := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/portal-customers/%d", legacyCustomerID), nil) + AssertStatusCode(t, getResp, http.StatusOK) + var fetched map[string]interface{} + DecodeJSON(t, getResp, &fetched) + fetchedCFV, _ := fetched["custom_field_values"].(map[string]interface{}) + if fetchedCFV[strconv.Itoa(legacyID)] != "silver" { + t.Fatalf("legacy read cfv = %v, want id key %d = silver", fetchedCFV, legacyID) + } + if _, ok := fetchedCFV[legacyName]; ok { + t.Fatalf("legacy read must strip the name key, got %v", fetchedCFV) + } + + // Duplicate names make the name key ambiguous: a write using it is rejected. + dupName := "CFV Dup " + ts() + createCustomerCustomField(t, server, map[string]interface{}{ + "name": dupName, + "field_type": "text", + "applies_to_portal_customers": true, + }) + createCustomerCustomField(t, server, map[string]interface{}{ + "name": dupName, + "field_type": "text", + "applies_to_portal_customers": true, + }) + dupResp := MakeAuthRequest(t, server, http.MethodPost, "/portal-customers", map[string]interface{}{ + "name": "CFV Dup Customer " + ts(), + "email": "cfv-dup-" + ts() + "@example.com", + "custom_field_values": map[string]interface{}{dupName: "x"}, + }) + _ = dupResp.Body.Close() + AssertStatusCode(t, dupResp, http.StatusBadRequest) +} + +// TestCustomerOrganisationCustomFieldValueKeys pins the same contract on the +// customer-organisation update path. +func TestCustomerOrganisationCustomFieldValueKeys(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + fieldName := "CFV Org Field " + ts() + fieldID := createCustomerCustomField(t, server, map[string]interface{}{ + "name": fieldName, + "field_type": "text", + "applies_to_customer_organisations": true, + }) + fieldKey := strconv.Itoa(fieldID) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/customer-organisations", map[string]interface{}{ + "name": "CFV Org " + ts(), + }) + AssertStatusCode(t, createResp, http.StatusCreated) + var created map[string]interface{} + DecodeJSON(t, createResp, &created) + orgID := ExtractIDFromResponse(t, created) + + updateResp := MakeAuthRequest(t, server, http.MethodPut, fmt.Sprintf("/customer-organisations/%d", orgID), map[string]interface{}{ + "name": created["name"], + "custom_field_values": map[string]interface{}{fieldName: "acme"}, + }) + AssertStatusCode(t, updateResp, http.StatusOK) + var updated map[string]interface{} + DecodeJSON(t, updateResp, &updated) + + updatedCFV, _ := updated["custom_field_values"].(map[string]interface{}) + if updatedCFV[fieldKey] != "acme" { + t.Fatalf("response cfv[%s] = %v, want acme (cfv=%v)", fieldKey, updatedCFV[fieldKey], updatedCFV) + } + if _, ok := updatedCFV[fieldName]; ok { + t.Fatalf("response must strip the unique-name mirror, got %v", updatedCFV) + } + + stored := readStoredCFV(t, server, "customer_organisations", orgID) + if stored[fieldKey] != "acme" || stored[fieldName] != "acme" { + t.Fatalf("stored cfv = %v, want id key %s and name mirror %q", stored, fieldKey, fieldName) + } +} + +func createCustomerCustomField(t *testing.T, server *TestServer, data map[string]interface{}) int { + t.Helper() + resp := MakeAuthRequest(t, server, http.MethodPost, "/admin/custom-fields", data) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusCreated) + var result map[string]interface{} + DecodeJSON(t, resp, &result) + return ExtractIDFromResponse(t, result) +} + +func readStoredCFV(t *testing.T, server *TestServer, table string, id int) map[string]interface{} { + t.Helper() + var raw string + //nolint:gosec // table is a test-controlled literal + if err := server.DB().QueryRow(fmt.Sprintf(`SELECT custom_field_values FROM %s WHERE id = ?`, table), id).Scan(&raw); err != nil { + t.Fatalf("read %s %d cfv: %v", table, id, err) + } + var out map[string]interface{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("decode %s %d cfv %q: %v", table, id, raw, err) + } + return out +} diff --git a/tests/portal_customer_is_primary_test.go b/tests/portal_customer_is_primary_test.go new file mode 100644 index 0000000..823e1e9 --- /dev/null +++ b/tests/portal_customer_is_primary_test.go @@ -0,0 +1,68 @@ +package tests + +import ( + "net/http" + "strconv" + "testing" +) + +// TestUpdatePortalCustomerPreservesOmittedIsPrimary pins the partial-update +// contract for the primary-contact flag: the detail edit form does not carry +// is_primary, so a metadata save must not demote the contact, while an explicit +// value still wins. +func TestUpdatePortalCustomerPreservesOmittedIsPrimary(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/portal-customers", map[string]any{ + "name": "Primary Contact", + "email": "primary-contact@test.local", + "is_primary": true, + }) + defer createResp.Body.Close() + AssertStatusCode(t, createResp, http.StatusCreated) + + var created struct { + ID int `json:"id"` + IsPrimary bool `json:"is_primary"` + } + DecodeJSON(t, createResp, &created) + if !created.IsPrimary { + t.Fatal("create did not persist is_primary") + } + + updateResp := MakeAuthRequest(t, server, http.MethodPut, "/portal-customers/"+strconv.Itoa(created.ID), map[string]any{ + "name": "Renamed Contact", + "email": "primary-contact@test.local", + }) + defer updateResp.Body.Close() + AssertStatusCode(t, updateResp, http.StatusOK) + + getResp := MakeAuthRequest(t, server, http.MethodGet, "/portal-customers/"+strconv.Itoa(created.ID), nil) + defer getResp.Body.Close() + AssertStatusCode(t, getResp, http.StatusOK) + var got struct { + IsPrimary bool `json:"is_primary"` + } + DecodeJSON(t, getResp, &got) + if !got.IsPrimary { + t.Error("is_primary was cleared by a payload that omitted it") + } + + // An explicit false still clears. + clearResp := MakeAuthRequest(t, server, http.MethodPut, "/portal-customers/"+strconv.Itoa(created.ID), map[string]any{ + "name": "Renamed Contact", + "email": "primary-contact@test.local", + "is_primary": false, + }) + defer clearResp.Body.Close() + AssertStatusCode(t, clearResp, http.StatusOK) + + var cleared struct { + IsPrimary bool `json:"is_primary"` + } + DecodeJSON(t, clearResp, &cleared) + if cleared.IsPrimary { + t.Error("is_primary = true, want false after an explicit clear") + } +} diff --git a/tests/portal_language_test.go b/tests/portal_language_test.go new file mode 100644 index 0000000..479cbdb --- /dev/null +++ b/tests/portal_language_test.go @@ -0,0 +1,96 @@ +package tests + +import ( + "net/http" + "testing" +) + +// TestPortalCustomerLanguageDetectionAndUpdate covers the portal language +// lifecycle: first contact stores the browser language once, the explicit +// toggle wins, and a later login never overwrites the chosen value. +func TestPortalCustomerLanguageDetectionAndUpdate(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "Portal Language WS", shortKey("PLG")) + portalSlug, channelID := SetupPortalChannel(t, server, wsID) + + const email = "portal-lang@example.test" + customerID, portalCookie := CreatePortalCustomerWithSession(t, server, channelID, "Language Customer", email) + + languageOf := func() string { + t.Helper() + var language string + if err := server.DB().QueryRow( + `SELECT COALESCE(language, '') FROM portal_customers WHERE id = ?`, customerID, + ).Scan(&language); err != nil { + t.Fatalf("read portal customer language: %v", err) + } + return language + } + + requestLink := func(acceptLanguage string) *http.Response { + t.Helper() + return makeRequest(t, http.MethodPost, + server.APIBase+"/portal/"+portalSlug+"/auth/request", "", + map[string]any{"email": email}, + map[string]string{"Accept-Language": acceptLanguage}) + } + + // First contact: the browser language is stored before the email renders. + first := requestLink("de-DE,de;q=0.9,en;q=0.8") + defer first.Body.Close() + AssertStatusCode(t, first, http.StatusOK) + if got := languageOf(); got != "de" { + t.Fatalf("language after first request = %q, want de", got) + } + + // The explicit toggle wins and is persisted. + update := MakePortalRequest(t, server, portalCookie, http.MethodPut, + "/portal/"+portalSlug+"/auth/me", map[string]any{"language": "es"}) + defer update.Body.Close() + AssertStatusCode(t, update, http.StatusOK) + if got := languageOf(); got != "es" { + t.Fatalf("language after update = %q, want es", got) + } + + // A later login must not overwrite the chosen language. + second := requestLink("fr") + defer second.Body.Close() + AssertStatusCode(t, second, http.StatusOK) + if got := languageOf(); got != "es" { + t.Fatalf("language after second request = %q, want es (unchanged)", got) + } +} + +// TestPortalCustomerLanguageUpdateValidation covers the PUT contract: only +// shipped locales and real timezones are accepted, and an internal or absent +// session cannot write a portal customer's preference. +func TestPortalCustomerLanguageUpdateValidation(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "Portal Language Validation WS", shortKey("PLV")) + portalSlug, channelID := SetupPortalChannel(t, server, wsID) + _, portalCookie := CreatePortalCustomerWithSession(t, server, channelID, "Validation Customer", "portal-lang-validation@example.test") + + endpoint := "/portal/" + portalSlug + "/auth/me" + + unsupported := MakePortalRequest(t, server, portalCookie, http.MethodPut, endpoint, + map[string]any{"language": "xx"}) + defer unsupported.Body.Close() + AssertStatusCode(t, unsupported, http.StatusBadRequest) + + badTimezone := MakePortalRequest(t, server, portalCookie, http.MethodPut, endpoint, + map[string]any{"language": "de", "timezone": "Not/AZone"}) + defer badTimezone.Body.Close() + AssertStatusCode(t, badTimezone, http.StatusBadRequest) + + // No portal session: the endpoint must not fall back to internal auth. + noSession := makeRequest(t, http.MethodPut, server.APIBase+endpoint, "", + map[string]any{"language": "de"}, nil) + defer noSession.Body.Close() + AssertStatusCode(t, noSession, http.StatusUnauthorized) +} diff --git a/tests/portal_regional_settings_test.go b/tests/portal_regional_settings_test.go new file mode 100644 index 0000000..d523db9 --- /dev/null +++ b/tests/portal_regional_settings_test.go @@ -0,0 +1,56 @@ +package tests + +import ( + "net/http" + "testing" +) + +// TestPortalRegionalSettingsPreservesOmittedTimezone pins the partial-update +// contract for PUT /portal/{slug}/auth/me: the profile language picker sends +// only {language}, so the customer's stored timezone must survive. +func TestPortalRegionalSettingsPreservesOmittedTimezone(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + wsID, _ := CreateTestWorkspace(t, server, "Portal Regional WS", shortKey("PRS")) + portalSlug, channelID := SetupPortalChannel(t, server, wsID) + _, sessionCookie := CreatePortalCustomerWithSession(t, server, channelID, "Regional User", "regional@test.local") + + // Seed a stored timezone. + setResp := MakePortalRequest(t, server, sessionCookie, http.MethodPut, "/portal/"+portalSlug+"/auth/me", map[string]any{ + "timezone": "America/New_York", + }) + defer setResp.Body.Close() + AssertStatusCode(t, setResp, http.StatusOK) + var set map[string]any + DecodeJSON(t, setResp, &set) + if set["timezone"] != "America/New_York" { + t.Fatalf("timezone = %v, want America/New_York", set["timezone"]) + } + + // A language-only save must keep it. + langResp := MakePortalRequest(t, server, sessionCookie, http.MethodPut, "/portal/"+portalSlug+"/auth/me", map[string]any{ + "language": "de", + }) + defer langResp.Body.Close() + AssertStatusCode(t, langResp, http.StatusOK) + var lang map[string]any + DecodeJSON(t, langResp, &lang) + if lang["language"] != "de" { + t.Errorf("language = %v, want de", lang["language"]) + } + if lang["timezone"] != "America/New_York" { + t.Errorf("timezone = %v, want America/New_York after a language-only save", lang["timezone"]) + } + + // Persistence: the stored row still carries the timezone. + var stored string + if err := server.DB().QueryRow( + `SELECT COALESCE(timezone, '') FROM portal_customers WHERE email = ?`, "regional@test.local", + ).Scan(&stored); err != nil { + t.Fatalf("read stored timezone: %v", err) + } + if stored != "America/New_York" { + t.Errorf("stored timezone = %q, want America/New_York", stored) + } +} diff --git a/tests/sla_import_test.go b/tests/sla_import_test.go index 5a1444a..8cb3561 100644 --- a/tests/sla_import_test.go +++ b/tests/sla_import_test.go @@ -143,6 +143,61 @@ func TestSLACycleImportIsIdempotent(t *testing.T) { } } +// TestSLAImportDefaultsMetricsInactive asserts an import that does not opt in +// leaves the metric inactive and never arms a live breach job for its cycles. +func TestSLAImportDefaultsMetricsInactive(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + _ = CreateBearerToken(t, server) + workspaceID, _ := CreateTestWorkspace(t, server, "SLA Inactive Workspace", shortKey("SIA")) + itemID := CreateTestItem(t, server, workspaceID, "Inactive import cycle item") + + document := slaImportDocument("jira-cal-inactive", "jira-sla-inactive") + metric := document["metrics"].([]map[string]any)[0] + delete(metric, "is_active") + document["cycles"] = []map[string]any{{ + "source_id": "jira-cycle-inactive", + "metric_source_id": "jira-sla-inactive", + "item_id": itemID, + "goal_source_id": "goal-1", + "calendar_source_id": "jira-cal-inactive", + "cycle_no": 1, + "status": "ongoing", + "started_at": "2026-01-01T00:00:00Z", + "elapsed_ms": 1000, + "goal_duration_ms": 3600000, + }} + + importResp := MakeAuthRequest(t, server, http.MethodPost, fmt.Sprintf("/workspaces/%d/sla/import", workspaceID), map[string]any{"mode": "merge", "document": document}) + AssertStatusCode(t, importResp, http.StatusOK) + _ = importResp.Body.Close() + + var active bool + if err := server.DB().QueryRow(`SELECT is_active FROM sla_metrics WHERE source_id = 'jira-sla-inactive'`).Scan(&active); err != nil { + t.Fatalf("load imported metric: %v", err) + } + if active { + t.Fatal("imported metric is active, want inactive by default") + } + + var cycleCount, breachJobs int + if err := server.DB().QueryRow(`SELECT COUNT(*) FROM item_sla_cycles WHERE source_id = 'jira-cycle-inactive'`).Scan(&cycleCount); err != nil { + t.Fatalf("count imported cycles: %v", err) + } + if cycleCount != 1 { + t.Fatalf("imported cycles = %d, want 1", cycleCount) + } + if err := server.DB().QueryRow(` + SELECT COUNT(*) FROM sla_jobs j + JOIN item_sla_cycles c ON c.id = j.cycle_id + WHERE c.source_id = 'jira-cycle-inactive' AND j.kind = 'breach' + `).Scan(&breachJobs); err != nil { + t.Fatalf("count imported cycle breach jobs: %v", err) + } + if breachJobs != 0 { + t.Fatalf("inactive imported cycle armed %d breach jobs, want 0", breachJobs) + } +} + // TestSLAImportNeedsAttentionKeepsGoalInactive asserts an untranslated JQL // clause is preserved and never silently widened. func TestSLAImportNeedsAttentionKeepsGoalInactive(t *testing.T) { diff --git a/tests/sso_provider_partial_update_test.go b/tests/sso_provider_partial_update_test.go new file mode 100644 index 0000000..dab9b81 --- /dev/null +++ b/tests/sso_provider_partial_update_test.go @@ -0,0 +1,111 @@ +package tests + +import ( + "net/http" + "strconv" + "testing" +) + +// TestCreateSSOProviderDefaultsOmittedBooleans keeps the create path honest: +// the pointer fields must still fall back to false when omitted. +func TestCreateSSOProviderDefaultsOmittedBooleans(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/sso/providers", map[string]any{ + "slug": "saml-defaults", + "name": "SAML defaults", + "provider_type": "saml", + "saml_idp_sso_url": "https://idp.example.com/sso", + "saml_idp_certificate": "cert", + }) + defer createResp.Body.Close() + AssertStatusCode(t, createResp, http.StatusCreated) + + var created struct { + Enabled bool `json:"enabled"` + IsDefault bool `json:"is_default"` + AutoProvisionUsers bool `json:"auto_provision_users"` + SAMLSignRequests bool `json:"saml_sign_requests"` + } + DecodeJSON(t, createResp, &created) + if created.Enabled || created.AutoProvisionUsers || created.SAMLSignRequests { + t.Errorf("omitted booleans = enabled:%v auto:%v saml_sign:%v, want all false", + created.Enabled, created.AutoProvisionUsers, created.SAMLSignRequests) + } + if !created.IsDefault { + t.Error("first provider is_default = false, want true") + } +} + +// TestUpdateSSOProviderPreservesOmittedFields pins the partial-update contract: +// a payload that omits booleans leaves the stored values alone, while an +// explicit value still wins. The admin edit form does not carry the SAML +// fields, so a metadata save used to clear saml_sign_requests. +func TestUpdateSSOProviderPreservesOmittedFields(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + createResp := MakeAuthRequest(t, server, http.MethodPost, "/sso/providers", map[string]any{ + "slug": "saml-partial", + "name": "SAML partial", + "provider_type": "saml", + "enabled": true, + "auto_provision_users": true, + "saml_idp_sso_url": "https://idp.example.com/sso", + "saml_idp_certificate": "cert", + "saml_sign_requests": true, + }) + defer createResp.Body.Close() + AssertStatusCode(t, createResp, http.StatusCreated) + + var created struct { + ID int `json:"id"` + } + DecodeJSON(t, createResp, &created) + + // The admin edit form omits the SAML fields; a rename must not clear them. + updateResp := MakeAuthRequest(t, server, http.MethodPut, "/sso/providers/"+strconv.Itoa(created.ID), map[string]any{ + "name": "Renamed", + }) + defer updateResp.Body.Close() + AssertStatusCode(t, updateResp, http.StatusOK) + + getResp := MakeAuthRequest(t, server, http.MethodGet, "/sso/providers/"+strconv.Itoa(created.ID), nil) + defer getResp.Body.Close() + AssertStatusCode(t, getResp, http.StatusOK) + var got struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + AutoProvisionUsers bool `json:"auto_provision_users"` + SAMLSignRequests bool `json:"saml_sign_requests"` + } + DecodeJSON(t, getResp, &got) + if got.Name != "Renamed" { + t.Errorf("name = %q, want Renamed", got.Name) + } + if !got.SAMLSignRequests { + t.Error("saml_sign_requests was cleared by a payload that omitted it") + } + if !got.Enabled { + t.Error("enabled was cleared by a payload that omitted it") + } + if !got.AutoProvisionUsers { + t.Error("auto_provision_users was cleared by a payload that omitted it") + } + + // An explicit false still clears. + clearResp := MakeAuthRequest(t, server, http.MethodPut, "/sso/providers/"+strconv.Itoa(created.ID), map[string]any{ + "saml_sign_requests": false, + }) + defer clearResp.Body.Close() + AssertStatusCode(t, clearResp, http.StatusOK) + + var cleared struct { + SAMLSignRequests bool `json:"saml_sign_requests"` + } + DecodeJSON(t, clearResp, &cleared) + if cleared.SAMLSignRequests { + t.Error("saml_sign_requests = true, want false after an explicit clear") + } +} diff --git a/tests/time_org_delete_worklog_test.go b/tests/time_org_delete_worklog_test.go new file mode 100644 index 0000000..e9b2b8e --- /dev/null +++ b/tests/time_org_delete_worklog_test.go @@ -0,0 +1,78 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +// TestDeletingCustomerOrganisationKeepsDetachedProjectWorklogs reproduces +// GitHub #403: unlinking a project from an organisation and then deleting the +// organisation must not delete the project's time worklogs. Worklogs derive +// their customer from the project (customer -> project -> worklog), so the +// organisation is not a worklog parent. +func TestDeletingCustomerOrganisationKeepsDetachedProjectWorklogs(t *testing.T) { + server, cleanup := StartTestServer(t, GetDBType()) + defer cleanup() + CreateBearerToken(t, server) + + createOrg := MakeAuthRequest(t, server, http.MethodPost, "/customer-organisations", + map[string]any{"name": "Doomed customer"}) + defer createOrg.Body.Close() + AssertStatusCode(t, createOrg, http.StatusCreated) + var org struct { + ID int `json:"id"` + } + DecodeJSON(t, createOrg, &org) + + createProject := MakeAuthRequest(t, server, http.MethodPost, "/time/projects", + map[string]any{"name": "Detachable project", "customer_id": org.ID}) + defer createProject.Body.Close() + AssertStatusCode(t, createProject, http.StatusCreated) + var project struct { + ID int `json:"id"` + } + DecodeJSON(t, createProject, &project) + + createWorklog := MakeAuthRequest(t, server, http.MethodPost, "/v2/time/worklogs", + map[string]any{"project_id": project.ID, "description": "Billable time", "date": "2026-09-02", "duration": "1h"}) + defer createWorklog.Body.Close() + AssertStatusCode(t, createWorklog, http.StatusCreated) + var worklog struct { + ID int `json:"id"` + CustomerID int `json:"customer_id"` + } + DecodeJSON(t, createWorklog, &worklog) + if worklog.CustomerID != org.ID { + t.Fatalf("worklog customer_id = %d, want organisation %d", worklog.CustomerID, org.ID) + } + + // Detach the project from the organisation, then delete the organisation. + detach := MakeAuthRequest(t, server, http.MethodPatch, fmt.Sprintf("/time/projects/%d", project.ID), + map[string]any{"customer_id": nil}) + defer detach.Body.Close() + AssertStatusCode(t, detach, http.StatusOK) + + removeOrg := MakeAuthRequest(t, server, http.MethodDelete, fmt.Sprintf("/customer-organisations/%d", org.ID), nil) + defer removeOrg.Body.Close() + AssertStatusCode(t, removeOrg, http.StatusNoContent) + + // The worklog must survive and now report the built-in no-customer + // placeholder. + list := MakeAuthRequest(t, server, http.MethodGet, fmt.Sprintf("/v2/time/worklogs?project_id=%d", project.ID), nil) + defer list.Body.Close() + AssertStatusCode(t, list, http.StatusOK) + var page struct { + Data []struct { + ID int `json:"id"` + CustomerBuiltinKey string `json:"customer_builtin_key"` + } `json:"data"` + } + DecodeJSON(t, list, &page) + if len(page.Data) != 1 || page.Data[0].ID != worklog.ID { + t.Fatalf("worklog list after organisation delete = %+v, want worklog %d", page.Data, worklog.ID) + } + if page.Data[0].CustomerBuiltinKey != "no_customer" { + t.Fatalf("worklog customer_builtin_key = %q, want no_customer", page.Data[0].CustomerBuiltinKey) + } +} diff --git a/tests/transition_screen_test.go b/tests/transition_screen_test.go new file mode 100644 index 0000000..ebff161 --- /dev/null +++ b/tests/transition_screen_test.go @@ -0,0 +1,382 @@ +package tests + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// assertTransitionScreenBinding asserts the governance payload carries a screen +// summary for the expected screen. +func assertTransitionScreenBinding(t *testing.T, governance map[string]interface{}, wantID int, wantNamePrefix string) { + t.Helper() + screen, ok := governance["screen"].(map[string]interface{}) + if !ok { + t.Fatalf("governance screen = %#v, want object", governance["screen"]) + } + if got := int(screen["id"].(float64)); got != wantID { + t.Fatalf("governance screen id = %d, want %d", got, wantID) + } + if wantNamePrefix != "" { + name, _ := screen["name"].(string) + if !strings.HasPrefix(name, wantNamePrefix) { + t.Fatalf("governance screen name = %q, want prefix %q", name, wantNamePrefix) + } + } + for _, field := range []string{"field_count", "required_field_count"} { + if _, ok := screen[field]; !ok { + t.Fatalf("governance screen summary missing %q", field) + } + } +} + +// TestTransitionScreenBinding covers the save-immediately binding API (v2): +// bind, read back through governance, the in-use delete guard, unbind, and the +// validation/not-found contracts. +func TestTransitionScreenBinding(t *testing.T) { + env := setupConditionTestData(t) + v2 := "/v2" + + screenName := "Close Screen " + shortKey("SCR") + screenResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/screens", map[string]interface{}{ + "name": screenName, + "description": "transition screen fixture", + }) + AssertStatusCode(t, screenResp, http.StatusCreated) + var screen map[string]interface{} + DecodeJSON(t, screenResp, &screen) + screenResp.Body.Close() + screenID := ExtractIDFromResponse(t, screen) + + transitionID := env.findTransitionID(t, env.openID, env.inProgressID) + screenPath := fmt.Sprintf("%s/transitions/%d/screen", v2, transitionID) + + // Bind and read the refreshed governance from the same response. + bindResp := MakeAuthRequest(t, env.server, http.MethodPut, screenPath, map[string]interface{}{"screen_id": screenID}) + AssertStatusCode(t, bindResp, http.StatusOK) + var bindGov map[string]interface{} + DecodeJSON(t, bindResp, &bindGov) + bindResp.Body.Close() + assertTransitionScreenBinding(t, bindGov, screenID, screenName) + + // The governance read reflects the binding. + govResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/transitions/%d/governance", v2, transitionID), nil) + AssertStatusCode(t, govResp, http.StatusOK) + var readGov map[string]interface{} + DecodeJSON(t, govResp, &readGov) + govResp.Body.Close() + assertTransitionScreenBinding(t, readGov, screenID, screenName) + + // An in-use screen cannot be deleted; the admin must unbind first. + deleteInUseResp := MakeAuthRequest(t, env.server, http.MethodDelete, fmt.Sprintf("%s/screens/%d", v2, screenID), nil) + AssertStatusCode(t, deleteInUseResp, http.StatusConflict) + deleteInUseResp.Body.Close() + + // Unbind. + unbindResp := MakeAuthRequest(t, env.server, http.MethodPut, screenPath, map[string]interface{}{"screen_id": nil}) + AssertStatusCode(t, unbindResp, http.StatusOK) + var unbindGov map[string]interface{} + DecodeJSON(t, unbindResp, &unbindGov) + unbindResp.Body.Close() + if unbindGov["screen"] != nil { + t.Fatalf("governance screen after unbind = %#v, want null", unbindGov["screen"]) + } + + // The screen is deletable once unbound. + deleteResp := MakeAuthRequest(t, env.server, http.MethodDelete, fmt.Sprintf("%s/screens/%d", v2, screenID), nil) + AssertStatusCode(t, deleteResp, http.StatusNoContent) + deleteResp.Body.Close() + + // A missing screen is a validation error, not a silent no-op. + missingScreenResp := MakeAuthRequest(t, env.server, http.MethodPut, screenPath, map[string]interface{}{"screen_id": 999999}) + AssertStatusCode(t, missingScreenResp, http.StatusBadRequest) + missingScreenResp.Body.Close() + + // A missing transition is not found. + missingTransitionResp := MakeAuthRequest(t, env.server, http.MethodPut, v2+"/transitions/999999/screen", map[string]interface{}{"screen_id": nil}) + AssertStatusCode(t, missingTransitionResp, http.StatusNotFound) + missingTransitionResp.Body.Close() +} + +type transitionScreenErrorDoc struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details struct { + MissingFields []struct { + Identifier string `json:"identifier"` + FieldType string `json:"field_type"` + } `json:"missing_fields"` + } `json:"details"` + } `json:"error"` +} + +// TestTransitionScreenEnforcement covers the runtime gate: a bound screen with +// a required field rejects the move with screen_required, the available- +// transitions read exposes the screen summary, and a submitted field is applied +// and persisted with the status change. +func TestTransitionScreenEnforcement(t *testing.T) { + env := setupConditionTestData(t) + v2 := "/v2" + + // The fixture item has no due date, so a required due_date gate fires. + screenName := "Enforce Screen " + shortKey("ENF") + screenResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/screens", map[string]interface{}{"name": screenName}) + AssertStatusCode(t, screenResp, http.StatusCreated) + var screen map[string]interface{} + DecodeJSON(t, screenResp, &screen) + screenResp.Body.Close() + screenID := ExtractIDFromResponse(t, screen) + + fieldsResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/screens/%d/fields", v2, screenID), map[string]interface{}{ + "fields": []map[string]interface{}{ + {"field_type": "system", "field_identifier": "due_date", "display_order": 0, "is_required": true, "field_width": "half"}, + }, + }) + AssertStatusCode(t, fieldsResp, http.StatusOK) + fieldsResp.Body.Close() + + transitionID := env.findTransitionID(t, env.openID, env.inProgressID) + bindResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/transitions/%d/screen", v2, transitionID), map[string]interface{}{"screen_id": screenID}) + AssertStatusCode(t, bindResp, http.StatusOK) + bindResp.Body.Close() + + // Available transitions advertise the screen so the client can open it. + availResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/items/%d/available-transitions", v2, env.itemID), nil) + AssertStatusCode(t, availResp, http.StatusOK) + var summary map[string]interface{} + DecodeJSON(t, availResp, &summary) + availResp.Body.Close() + options, _ := summary["available_transitions"].([]interface{}) + var matched map[string]interface{} + for _, raw := range options { + option, _ := raw.(map[string]interface{}) + if option != nil && int(option["id"].(float64)) == env.inProgressID { + matched = option + break + } + } + if matched == nil { + t.Fatalf("available transitions did not include status %d: %#v", env.inProgressID, options) + } + if matched["screen"] == nil { + t.Fatal("available transition is missing the screen summary") + } + + // A move without the required field is rejected with screen_required and a + // structured missing-fields payload, and does not change status. + rejectResp := MakeAuthRequest(t, env.server, http.MethodPost, fmt.Sprintf("%s/items/%d/transition", v2, env.itemID), map[string]interface{}{"to_status_id": env.inProgressID}) + AssertStatusCode(t, rejectResp, http.StatusBadRequest) + var rejectDoc transitionScreenErrorDoc + DecodeJSON(t, rejectResp, &rejectDoc) + rejectResp.Body.Close() + if rejectDoc.Error.Code != "screen_required" { + t.Fatalf("rejection code = %q, want screen_required", rejectDoc.Error.Code) + } + var sawPriority bool + for _, field := range rejectDoc.Error.Details.MissingFields { + if field.Identifier == "due_date" { + sawPriority = true + } + } + if !sawPriority { + t.Fatalf("missing_fields = %#v, want due_date", rejectDoc.Error.Details.MissingFields) + } + + // The item did not move. + itemResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/items/%d", v2, env.itemID), nil) + AssertStatusCode(t, itemResp, http.StatusOK) + var item map[string]interface{} + DecodeJSON(t, itemResp, &item) + itemResp.Body.Close() + if got := int(item["status_id"].(float64)); got != env.openID { + t.Fatalf("status after rejected transition = %d, want %d", got, env.openID) + } + + // Submitting the required field moves the item and persists the value. + okResp := MakeAuthRequest(t, env.server, http.MethodPost, fmt.Sprintf("%s/items/%d/transition", v2, env.itemID), map[string]interface{}{ + "to_status_id": env.inProgressID, + "fields": map[string]interface{}{"due_date": "2026-12-31"}, + }) + AssertStatusCode(t, okResp, http.StatusOK) + var transitioned map[string]interface{} + DecodeJSON(t, okResp, &transitioned) + okResp.Body.Close() + moved, _ := transitioned["item"].(map[string]interface{}) + if moved == nil { + t.Fatalf("transition response missing item: %#v", transitioned) + } + if got := int(moved["status_id"].(float64)); got != env.inProgressID { + t.Fatalf("status after transition = %d, want %d", got, env.inProgressID) + } + due, _ := moved["due_date"].(string) + if !strings.HasPrefix(due, "2026-12-31") { + t.Fatalf("due_date after transition = %q, want 2026-12-31", due) + } +} + +// TestTransitionScreenCommentAndLabels covers the two values a transition +// screen collects that are not item-update fields: the optional comment is +// persisted as an item comment, and a required labels field can be satisfied +// through the transition itself. +func TestTransitionScreenCommentAndLabels(t *testing.T) { + env := setupConditionTestData(t) + v2 := "/v2" + + label := createLabelFx(t, env.server, env.workspaceID, "Screen Label "+shortKey("LBL"), "#123456") + + screenResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/screens", map[string]interface{}{"name": "Comment Labels " + shortKey("CLB")}) + AssertStatusCode(t, screenResp, http.StatusCreated) + var screen map[string]interface{} + DecodeJSON(t, screenResp, &screen) + screenResp.Body.Close() + screenID := ExtractIDFromResponse(t, screen) + + fieldsResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/screens/%d/fields", v2, screenID), map[string]interface{}{ + "fields": []map[string]interface{}{ + {"field_type": "system", "field_identifier": "labels", "display_order": 0, "is_required": true, "field_width": "half"}, + }, + }) + AssertStatusCode(t, fieldsResp, http.StatusOK) + fieldsResp.Body.Close() + + transitionID := env.findTransitionID(t, env.openID, env.inProgressID) + bindResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/transitions/%d/screen", v2, transitionID), map[string]interface{}{"screen_id": screenID}) + AssertStatusCode(t, bindResp, http.StatusOK) + bindResp.Body.Close() + + // Without labels the move is refused. + rejectResp := MakeAuthRequest(t, env.server, http.MethodPost, fmt.Sprintf("%s/items/%d/transition", v2, env.itemID), map[string]interface{}{"to_status_id": env.inProgressID}) + AssertStatusCode(t, rejectResp, http.StatusBadRequest) + rejectResp.Body.Close() + + // Supplying labels and a comment moves the item, persists the labels, and + // records the comment. + okResp := MakeAuthRequest(t, env.server, http.MethodPost, fmt.Sprintf("%s/items/%d/transition", v2, env.itemID), map[string]interface{}{ + "to_status_id": env.inProgressID, + "fields": map[string]interface{}{ + "label_ids": []int{label.ID}, + "comment": "Moving this along", + }, + }) + AssertStatusCode(t, okResp, http.StatusOK) + okResp.Body.Close() + + labelsResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/items/%d/labels", v2, env.itemID), nil) + AssertStatusCode(t, labelsResp, http.StatusOK) + var labels []map[string]interface{} + DecodeJSON(t, labelsResp, &labels) + labelsResp.Body.Close() + if len(labels) != 1 || int(labels[0]["id"].(float64)) != label.ID { + t.Fatalf("labels after transition = %#v, want just %d", labels, label.ID) + } + + commentsResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/items/%d/comments", v2, env.itemID), nil) + AssertStatusCode(t, commentsResp, http.StatusOK) + var commentsDoc struct { + Comments []map[string]interface{} `json:"comments"` + } + DecodeJSON(t, commentsResp, &commentsDoc) + commentsResp.Body.Close() + var sawComment bool + for _, comment := range commentsDoc.Comments { + if strings.Contains(fmt.Sprint(comment["content"]), "Moving this along") { + sawComment = true + } + } + if !sawComment { + t.Fatalf("transition comment not persisted: %#v", commentsDoc.Comments) + } +} + +// TestBulkTransitionRefusesScreenBlockedBatch covers the bulk contract: a bulk +// caller cannot collect screen field values, so a batch containing a blocked +// item is refused as a whole instead of partially applied, and the client may +// retry without the blocked items. +func TestBulkTransitionRefusesScreenBlockedBatch(t *testing.T) { + env := setupConditionTestData(t) + v2 := "/v2" + + // A required due_date gate on Open -> In Progress. + screenResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/screens", map[string]interface{}{"name": "Bulk Screen " + shortKey("BLK")}) + AssertStatusCode(t, screenResp, http.StatusCreated) + var screen map[string]interface{} + DecodeJSON(t, screenResp, &screen) + screenResp.Body.Close() + screenID := ExtractIDFromResponse(t, screen) + + fieldsResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/screens/%d/fields", v2, screenID), map[string]interface{}{ + "fields": []map[string]interface{}{ + {"field_type": "system", "field_identifier": "due_date", "display_order": 0, "is_required": true, "field_width": "half"}, + }, + }) + AssertStatusCode(t, fieldsResp, http.StatusOK) + fieldsResp.Body.Close() + + transitionID := env.findTransitionID(t, env.openID, env.inProgressID) + bindResp := MakeAuthRequest(t, env.server, http.MethodPut, fmt.Sprintf("%s/transitions/%d/screen", v2, transitionID), map[string]interface{}{"screen_id": screenID}) + AssertStatusCode(t, bindResp, http.StatusOK) + bindResp.Body.Close() + + // One item already satisfies the required field; the other does not. + satisfied := CreateTestItem(t, env.server, env.workspaceID, "Has a due date") + blocked := CreateTestItem(t, env.server, env.workspaceID, "Needs a due date") + patchResp := MakeAuthRequest(t, env.server, http.MethodPatch, fmt.Sprintf("%s/items/%d", v2, satisfied), map[string]interface{}{"due_date": "2026-12-31T00:00:00Z"}) + AssertStatusCode(t, patchResp, http.StatusOK) + patchResp.Body.Close() + + // The batch is refused as a whole: 409 with the blocked item listed, and + // even the satisfiable item does not move. + bulkResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/items/bulk-transition", map[string]interface{}{ + "item_ids": []int{satisfied, blocked}, + "to_status_id": env.inProgressID, + }) + AssertStatusCode(t, bulkResp, http.StatusConflict) + var bulkDoc struct { + Error struct { + Code string `json:"code"` + Details struct { + Items []struct { + ItemID int `json:"item_id"` + Key string `json:"key"` + } `json:"items"` + } `json:"details"` + } `json:"error"` + } + DecodeJSON(t, bulkResp, &bulkDoc) + bulkResp.Body.Close() + if bulkDoc.Error.Code != "screen_required" { + t.Fatalf("bulk refusal code = %q, want screen_required", bulkDoc.Error.Code) + } + if len(bulkDoc.Error.Details.Items) != 1 || bulkDoc.Error.Details.Items[0].ItemID != blocked { + t.Fatalf("blocked items = %#v, want just item %d", bulkDoc.Error.Details.Items, blocked) + } + if bulkDoc.Error.Details.Items[0].Key == "" { + t.Fatalf("blocked item is missing its display key: %#v", bulkDoc.Error.Details.Items[0]) + } + + for _, id := range []int{satisfied, blocked} { + statusResp := MakeAuthRequest(t, env.server, http.MethodGet, fmt.Sprintf("%s/items/%d", v2, id), nil) + AssertStatusCode(t, statusResp, http.StatusOK) + var item map[string]interface{} + DecodeJSON(t, statusResp, &item) + statusResp.Body.Close() + if got := int(item["status_id"].(float64)); got != env.openID { + t.Fatalf("item %d status after refused batch = %d, want %d", id, got, env.openID) + } + } + + // Retrying without the blocked item moves the rest. + retryResp := MakeAuthRequest(t, env.server, http.MethodPost, v2+"/items/bulk-transition", map[string]interface{}{ + "item_ids": []int{satisfied}, + "to_status_id": env.inProgressID, + }) + AssertStatusCode(t, retryResp, http.StatusOK) + var retry map[string]interface{} + DecodeJSON(t, retryResp, &retry) + retryResp.Body.Close() + if got := int(retry["transitioned_count"].(float64)); got != 1 { + t.Fatalf("retry transitioned_count = %d, want 1", got) + } +} diff --git a/tests/user_directory_surface_convergence_test.go b/tests/user_directory_surface_convergence_test.go index 8e785a6..5757e17 100644 --- a/tests/user_directory_surface_convergence_test.go +++ b/tests/user_directory_surface_convergence_test.go @@ -80,6 +80,67 @@ func TestUserDirectoryReadsConvergeAcrossSessionAndBearerSurfaces(t *testing.T) } } +// The self endpoint is the only user read that may expose private profile +// fields. The directory summary deliberately omits them (see the email check in +// TestUserDirectoryReadsConvergeAcrossSessionAndBearerSurfaces), so the profile +// page must not source its email, timezone, or language from the directory. +func TestV2SelfProfileIncludesPrivateFields(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + userID, username, _ := CreateTestUserWithCredentials(t, server, "self_profile_reader", "self_profile_reader@test.com") + + update := MakeAuthRequest(t, server, http.MethodPut, + fmt.Sprintf("/users/%d/regional-settings", userID), map[string]any{ + "timezone": "Europe/Zurich", + "language": "de", + }) + defer update.Body.Close() + AssertStatusCode(t, update, http.StatusOK) + + reset := MakeAuthRequest(t, server, http.MethodPost, + fmt.Sprintf("/users/%d/reset-password", userID), map[string]any{"generate_random": true}) + defer reset.Body.Close() + AssertStatusCode(t, reset, http.StatusOK) + var resetBody struct { + TemporaryPassword string `json:"temporary_password"` + } + DecodeJSON(t, reset, &resetBody) + if resetBody.TemporaryPassword == "" { + t.Fatal("reset-password did not return a temporary password") + } + + token := createTokenWithScopesAsUser(t, server, username, resetBody.TemporaryPassword, []string{"users:read"}) + resp := MakeBearerRequestWithToken(t, server, token, http.MethodGet, "/rest/api/v2/users/me", nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + + var profile struct { + ID int `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + FullName string `json:"full_name"` + Timezone string `json:"timezone"` + Language string `json:"language"` + RequiresPasswordReset bool `json:"requires_password_reset"` + } + DecodeJSON(t, resp, &profile) + if profile.ID != userID || profile.Username != username { + t.Fatalf("self profile identity = %+v, want id=%d username=%q", profile, userID, username) + } + if profile.Email != "self_profile_reader@test.com" { + t.Fatalf("self profile email = %q", profile.Email) + } + if profile.Timezone != "Europe/Zurich" || profile.Language != "de" { + t.Fatalf("self profile regional settings = %+v", profile) + } + if profile.FullName != "Test User" { + t.Fatalf("self profile full_name = %q", profile.FullName) + } + if !profile.RequiresPasswordReset { + t.Fatalf("self profile requires_password_reset = false after a password reset") + } +} + func directoryContainsUser(users []surfaceDirectoryUser, userID int) bool { for _, user := range users { if user.ID == userID { diff --git a/tests/workspace_id_batch_http_test.go b/tests/workspace_id_batch_http_test.go new file mode 100644 index 0000000..690849a --- /dev/null +++ b/tests/workspace_id_batch_http_test.go @@ -0,0 +1,98 @@ +package tests + +import ( + "io" + "net/http" + "strconv" + "strings" + "testing" +) + +// TestV2WorkspaceIDsResolve covers GET /workspaces?ids=: one bounded read +// resolves a known workspace id set, intersected with the caller's visible +// workspaces, so callers do not issue one request per id. +func TestV2WorkspaceIDsResolve(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + + visibleA, _ := CreateTestWorkspace(t, server, "ID batch alpha", shortKey("IBA")) + visibleB, _ := CreateTestWorkspace(t, server, "ID batch beta", shortKey("IBB")) + hidden, _ := CreateTestWorkspace(t, server, "ID batch hidden", shortKey("IBH")) + + userID, username, password := CreateTestUserWithCredentials(t, server, "id_batch_reader", "id_batch_reader@test.local") + AssignWorkspaceRole(t, server, userID, visibleA, "Viewer") + AssignWorkspaceRole(t, server, userID, visibleB, "Viewer") + // Restrict the third workspace to another user: assigning a Viewer makes + // it restricted, and id_batch_reader has no role on it. + hiddenViewerID, _, _ := CreateTestUserWithCredentials(t, server, "id_batch_hidden_viewer", "id_batch_hidden_viewer@test.local") + AssignWorkspaceRole(t, server, hiddenViewerID, hidden, "Viewer") + token := createTokenWithScopesAsUser(t, server, username, password, []string{"workspaces:read"}) + + resolve := func(t *testing.T, query string) map[int]bool { + t.Helper() + resp := MakeBearerRequestWithToken(t, server, token, http.MethodGet, + "/rest/api/v2/workspaces?"+query, nil) + defer resp.Body.Close() + AssertStatusCode(t, resp, http.StatusOK) + var page struct { + Data []map[string]any `json:"data"` + } + DecodeJSON(t, resp, &page) + got := map[int]bool{} + for _, row := range page.Data { + got[int(row["id"].(float64))] = true + } + return got + } + + t.Run("returnsOnlyTheRequestedVisibleWorkspaces", func(t *testing.T) { + got := resolve(t, "ids="+strconv.Itoa(visibleA)+","+strconv.Itoa(visibleB)+","+strconv.Itoa(hidden)) + if !got[visibleA] || !got[visibleB] { + t.Fatalf("visible workspaces missing from resolve: %v", got) + } + if got[hidden] { + t.Fatalf("workspace %d without a role leaked into the resolve: %v", hidden, got) + } + if len(got) != 2 { + t.Fatalf("resolve returned %d workspaces, want 2: %v", len(got), got) + } + }) + + t.Run("idsIgnoreSearchAndPageSize", func(t *testing.T) { + got := resolve(t, "ids="+strconv.Itoa(visibleA)+"&search=no-such-workspace&page_size=1") + if len(got) != 1 || !got[visibleA] { + t.Fatalf("ids did not take precedence over search/page_size: %v", got) + } + }) + + t.Run("unknownIdsAreOmittedWithoutFailingTheList", func(t *testing.T) { + got := resolve(t, "ids="+strconv.Itoa(visibleA)+",999999") + if len(got) != 1 || !got[visibleA] { + t.Fatalf("an unknown id changed the result: %v", got) + } + }) + + t.Run("moreThanTheCapIsRejected", func(t *testing.T) { + ids := make([]string, 0, 201) + for i := 1; i <= 201; i++ { + ids = append(ids, strconv.Itoa(i)) + } + resp := MakeBearerRequestWithToken(t, server, token, http.MethodGet, + "/rest/api/v2/workspaces?ids="+strings.Join(ids, ","), nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("201 ids = %d, want 400: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("invalidIdIsRejected", func(t *testing.T) { + resp := MakeBearerRequestWithToken(t, server, token, http.MethodGet, + "/rest/api/v2/workspaces?ids=not-an-id", nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("invalid id = %d, want 400: %s", resp.StatusCode, string(body)) + } + }) +}