From e3363c2ee6368c8cca97b7c7f86e11142f032b77 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:30:37 +0200 Subject: [PATCH] add api composable tests in web --- web/CLAUDE.md | 8 + web/app/composables/api/file-utils.test.ts | 97 + .../composables/api/prometheus-utils.test.ts | 224 +++ web/app/composables/api/test-helpers.ts | 159 ++ .../composables/api/useAuthAPI.nuxt.test.ts | 39 + .../api/useDataSourceAPI.nuxt.test.ts | 830 ++++++++ .../api/useDocumentAPI.nuxt.test.ts | 1773 +++++++++++++++++ .../api/useDocumentCommentAPI.nuxt.test.ts | 1344 +++++++++++++ .../api/useDocumentFileAPI.nuxt.test.ts | 123 ++ .../api/useDocumentHookAPI.nuxt.test.ts | 699 +++++++ .../composables/api/useGitHubAPI.nuxt.test.ts | 393 ++++ .../api/useNotificationAPI.nuxt.test.ts | 361 ++++ .../api/useOrganizationAPI.nuxt.test.ts | 128 ++ .../usePrometheusDataSourceAPI.nuxt.test.ts | 333 ++++ .../api/useSQLDataSourceAPI.nuxt.test.ts | 121 ++ .../composables/api/useSlackAPI.nuxt.test.ts | 559 ++++++ .../composables/api/useUserAPI.nuxt.test.ts | 92 + web/nuxt.config.ts | 1 + web/vitest.config.ts | 11 +- 19 files changed, 7291 insertions(+), 4 deletions(-) create mode 100644 web/app/composables/api/file-utils.test.ts create mode 100644 web/app/composables/api/prometheus-utils.test.ts create mode 100644 web/app/composables/api/test-helpers.ts create mode 100644 web/app/composables/api/useAuthAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useDataSourceAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useDocumentAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useDocumentFileAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useDocumentHookAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useGitHubAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useNotificationAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useOrganizationAPI.nuxt.test.ts create mode 100644 web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useSlackAPI.nuxt.test.ts create mode 100644 web/app/composables/api/useUserAPI.nuxt.test.ts diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 05048bed..6582dd70 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -320,6 +320,14 @@ it.for([ (`const stubDb = (err?: Error) => …`) defined next to the tests that use them; dependencies shared across many tests become `test.extend` fixtures. +- Helpers repeated across a directory's test files live in a colocated + `test-helpers.ts` module (e.g. + [app/composables/api/test-helpers.ts](app/composables/api/test-helpers.ts)). + It must never be re-exported for app use (in `composables/`, keep it + out of `index.ts` — nested files only enter the auto-imports through + that re-export), and the `app/**/test-helpers.ts` coverage exclude in + [vitest.config.ts](vitest.config.ts) keeps it out of the coverage + denominator. Domain-specific fixtures and factories stay file-local. - **One act per test**: arrange once, invoke the unit once, then assert as many facets of that single outcome as needed — result, error, and interactions all belong in the same test when they describe one diff --git a/web/app/composables/api/file-utils.test.ts b/web/app/composables/api/file-utils.test.ts new file mode 100644 index 00000000..e12d78ed --- /dev/null +++ b/web/app/composables/api/file-utils.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, it, vi } from "vitest" +import { + getStorageErrorCode, + handleStorageError, + StorageErrorCode, +} from "./file-utils" + +const { showToastMessageMock } = vi.hoisted(() => { + return { showToastMessageMock: vi.fn() } +}) + +vi.mock("~/components/toast", () => { + return { showToastMessage: showToastMessageMock } +}) + +// the minimal shape getStorageErrorCode's fetch error guard looks for +function fetchError(data: unknown) { + return { data, statusCode: 400 } +} + +describe("getStorageErrorCode", () => { + it.for([ + { name: "returns null for a null error", error: null }, + { name: "returns null for a string error", error: "boom" }, + { + name: "returns null for an object without fetch error fields", + error: { message: "boom" }, + }, + { + name: "returns null when the response data is not an object", + error: fetchError("boom"), + }, + { + name: "returns null when the response data has no code", + error: fetchError({ message: "boom" }), + }, + { + name: "returns null when the code is not a string", + error: fetchError({ code: 5 }), + }, + { + name: "returns null for an unknown storage code", + error: fetchError({ code: "storage.unknown" }), + }, + ])("$name", ({ error }, { expect }) => { + expect(getStorageErrorCode(error)).toBeNull() + }) + + it.for([ + { code: StorageErrorCode.InvalidContentType }, + { code: StorageErrorCode.SizeLimitExceeded }, + ])("extracts the $code code from a fetch error", ({ code }, { expect }) => { + expect(getStorageErrorCode(fetchError({ code }))).toBe(code) + }) +}) + +// the tests assert call accounting on the shared module-level toast mock, +// so they cannot interleave +describe("handleStorageError", { concurrent: false }, () => { + // restoreMocks does not touch hand-made vi.fn() singletons in vitest 4 + // — reset the module-level mock explicitly + beforeEach(() => { + showToastMessageMock.mockReset() + }) + + const t = (key: string) => `t:${key}` + + it("returns false without a toast for a non-storage error", ({ expect }) => { + const handled = handleStorageError(new Error("boom"), t) + + expect(handled).toBe(false) + expect(showToastMessageMock).toHaveBeenCalledTimes(0) + }) + + it.for([ + { + code: StorageErrorCode.InvalidContentType, + messageKey: "storage-errors.invalid-content-type", + }, + { + code: StorageErrorCode.SizeLimitExceeded, + messageKey: "storage-errors.size-limit-exceeded", + }, + ])( + "shows the error toast for the $code code", + ({ code, messageKey }, { expect }) => { + const handled = handleStorageError(fetchError({ code }), t) + + expect(handled).toBe(true) + expect(showToastMessageMock).toHaveBeenCalledExactlyOnceWith( + "error", + `t:${messageKey}.title`, + `t:${messageKey}.description`, + ) + }, + ) +}) diff --git a/web/app/composables/api/prometheus-utils.test.ts b/web/app/composables/api/prometheus-utils.test.ts new file mode 100644 index 00000000..dc4c660a --- /dev/null +++ b/web/app/composables/api/prometheus-utils.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest" +import { Matcher } from "@prometheus-io/codemirror-promql/dist/esm/types" +import { EqlRegex, EqlSingle, Neq, NeqRegex } from "@prometheus-io/lezer-promql" +import { TimeRangePreset } from "~/components/editor/blocks/metrics/utils" +import { PrometheusDataSourceClient } from "./prometheus-utils" + +const NOW = new Date("2026-01-02T03:00:00.000Z") +const FROM = new Date("2026-01-02T02:55:00.000Z") // NOW minus 5 minutes + +const TIME_PARAMS = { + from: FROM, + to: NOW, + timeRangeKey: TimeRangePreset.Last5Minutes, +} + +function makeMatcher(name: string, value: string, type: number) { + return new Matcher(type, name, value) +} + +function makeClient( + overrides: Partial< + ConstructorParameters[0] + > = {}, +) { + const fns = { + timeRangeFn: vi.fn().mockResolvedValue(TimeRangePreset.Last5Minutes), + metricMetadataFn: vi.fn().mockResolvedValue({ result: {} }), + labelNamesFn: vi.fn().mockResolvedValue({ result: [] }), + labelValuesFn: vi.fn().mockResolvedValue({ result: [] }), + seriesFn: vi.fn().mockResolvedValue({ result: [] }), + } + + return { + client: new PrometheusDataSourceClient({ ...fns, ...overrides }), + fns, + } +} + +// the tests pin the global clock with fake timers — shared mutable state +// that per-test hooks would corrupt mid-flight — so they cannot interleave +describe("PrometheusDataSourceClient", { concurrent: false }, () => { + // the client resolves its query window from the wall clock, so the + // tests pin it to a fixed instant + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("labelNames", () => { + it("queries label names for the resolved time range", async ({ + expect, + }) => { + const labelNamesFn = vi + .fn() + .mockResolvedValue({ result: ["job", "instance"] }) + const { client, fns } = makeClient({ labelNamesFn }) + + const result = await client.labelNames() + + expect(result).toEqual(["job", "instance"]) + expect(fns.timeRangeFn).toHaveBeenCalledTimes(1) + expect(labelNamesFn).toHaveBeenCalledExactlyOnceWith(TIME_PARAMS) + }) + + it("restricts label names to the given metric", async ({ expect }) => { + const { client, fns } = makeClient() + + await client.labelNames("up") + + expect(fns.labelNamesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + matchers: ["up"], + }) + }) + }) + + describe("labelValues", () => { + it("queries the values of a label without a metric", async ({ expect }) => { + const labelValuesFn = vi.fn().mockResolvedValue({ result: ["a", "b"] }) + const { client, fns } = makeClient({ labelValuesFn }) + + const result = await client.labelValues("job") + + expect(result).toEqual(["a", "b"]) + expect(fns.timeRangeFn).toHaveBeenCalledTimes(1) + expect(labelValuesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + label: "job", + }) + }) + + it("matches on the bare metric when there are no matchers", async ({ + expect, + }) => { + const { client, fns } = makeClient() + + await client.labelValues("job", "up") + + expect(fns.labelValuesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + label: "job", + matchers: ["up"], + }) + }) + + it("builds a selector from the matchers, skipping the queried label and empty values", async ({ + expect, + }) => { + const { client, fns } = makeClient() + + await client.labelValues("instance", "up", [ + makeMatcher("job", "api", EqlSingle), + makeMatcher("env", "prod", Neq), + makeMatcher("pod", "a.*", EqlRegex), + makeMatcher("node", "b.*", NeqRegex), + makeMatcher("empty", "", EqlSingle), + makeMatcher("instance", "x", EqlSingle), + ]) + + expect(fns.labelValuesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + label: "instance", + matchers: ['up{job="api",env!="prod",pod=~"a.*",node!~"b.*"}'], + }) + }) + + it("falls back to equality for an unknown matcher type", async ({ + expect, + }) => { + const { client, fns } = makeClient() + + await client.labelValues("instance", "up", [ + makeMatcher("job", "api", -1), + ]) + + expect(fns.labelValuesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + label: "instance", + matchers: ['up{job="api"}'], + }) + }) + }) + + describe("metricMetadata", () => { + it("returns the metadata result", async ({ expect }) => { + const metadata = { up: [{ type: "gauge", help: "", unit: "" }] } + const metricMetadataFn = vi.fn().mockResolvedValue({ result: metadata }) + const { client } = makeClient({ metricMetadataFn }) + + const result = await client.metricMetadata() + + expect(result).toBe(metadata) + expect(metricMetadataFn).toHaveBeenCalledTimes(1) + }) + }) + + describe("series", () => { + it("queries the series and maps each result into label maps", async ({ + expect, + }) => { + const seriesFn = vi.fn().mockResolvedValue({ + result: [{ __name__: "up", job: "api" }, { __name__: "up" }], + }) + const { client, fns } = makeClient({ seriesFn }) + + const result = await client.series("up", [ + makeMatcher("job", "api", EqlSingle), + ]) + + expect(result).toEqual([ + new Map([ + ["__name__", "up"], + ["job", "api"], + ]), + new Map([["__name__", "up"]]), + ]) + expect(fns.timeRangeFn).toHaveBeenCalledTimes(1) + expect(seriesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + matchers: ['up{job="api"}'], + }) + }) + }) + + describe("metricNames", () => { + it("queries the values of the __name__ label", async ({ expect }) => { + const labelValuesFn = vi.fn().mockResolvedValue({ result: ["up"] }) + const { client } = makeClient({ labelValuesFn }) + + const result = await client.metricNames() + + expect(result).toEqual(["up"]) + expect(labelValuesFn).toHaveBeenCalledExactlyOnceWith({ + ...TIME_PARAMS, + label: "__name__", + }) + }) + }) + + describe("flags", () => { + it("resolves an empty flag set", async ({ expect }) => { + const { client, fns } = makeClient() + + const result = await client.flags() + + expect(result).toEqual({}) + expect(fns.timeRangeFn).toHaveBeenCalledTimes(0) + }) + }) + + describe("destroy", () => { + it("is a no-op", ({ expect }) => { + const { client, fns } = makeClient() + + client.destroy() + + expect(fns.timeRangeFn).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/web/app/composables/api/test-helpers.ts b/web/app/composables/api/test-helpers.ts new file mode 100644 index 00000000..bb261383 --- /dev/null +++ b/web/app/composables/api/test-helpers.ts @@ -0,0 +1,159 @@ +// shared helpers for the api composable test suites in this directory. +// Deliberately not re-exported from app/composables/index.ts: nested +// composables/ files only enter the app's auto-imports through that +// re-export, so nothing here leaks into the app bundle. +import { registerEndpoint } from "@nuxt/test-utils/runtime" +import type { EntryKey } from "@pinia/colada" +import { getQuery, readBody, type H3Event } from "h3" +import { expect as staticExpect } from "vitest" + +// the composables' queries inject() their option defaults, which vue only +// allows inside a component or an app context — runWithContext provides +// the latter. The assertion is for eslint's ts program, which resolves +// calls through runWithContext as error typed; vue-tsc infers them fine. +export function runInApp(fn: () => T): T { + return useNuxtApp().runWithContext(fn) as T +} + +// isXid() only checks for a 20-character length; padding a short prefix +// makes ids that pass the mutations' id guards +export function makeXid(prefix: string): string { + return prefix.padEnd(20, "0") +} + +export interface RecordedCall { + query: Record + body: unknown +} + +const disposers: (() => void)[] = [] + +// registers a request handler on the test-time h3 app that the real api +// clients route through, recording each call for accounting. The returned +// array fills up as requests arrive. The raw event is passed through for +// responders that need to shape the response beyond a body (e.g. +// setResponseStatus). +export function mockEndpoint( + method: "GET" | "POST" | "PUT" | "DELETE", + url: string, + respond: (call: RecordedCall, event: H3Event) => unknown, +): RecordedCall[] { + const calls: RecordedCall[] = [] + + const dispose = registerEndpoint(url, { + method, + handler: async (event: H3Event) => { + const call: RecordedCall = { + query: getQuery(event), + body: + method === "POST" || method === "PUT" + ? await readBody(event) + : undefined, + } + + calls.push(call) + + return respond(call, event) + }, + }) + + disposers.push(dispose) + + return calls +} + +// packages the deferred-response pattern used to inspect optimistic cache +// state while the request is held in flight +export function mockDeferredEndpoint( + method: "GET" | "POST" | "PUT" | "DELETE", + url: string, +) { + let resolveRequest: (value: unknown) => void = () => undefined + let rejectRequest: (err: unknown) => void = () => undefined + let requestReached: () => void = () => undefined + const reached = new Promise((resolve) => { + requestReached = resolve + }) + + const calls = mockEndpoint(method, url, () => { + requestReached() + + return new Promise((resolve, reject) => { + resolveRequest = resolve + rejectRequest = reject + }) + }) + + return { + calls, + reached, + resolve: (value: unknown) => { + resolveRequest(value) + }, + reject: (err: unknown) => { + rejectRequest(err) + }, + } +} + +// folds a custom registerEndpoint helper's dispose function into +// disposeMockEndpoints' cleanup +export function trackEndpointDisposal(dispose: () => void) { + disposers.push(dispose) +} + +// removes every tracked handler registration — call from an afterEach so +// registrations never leak between tests +export function disposeMockEndpoints() { + disposers.splice(0).forEach((dispose) => { + dispose() + }) +} + +// queries with equal keys share app-wide cache entries between tests — +// dropping every entry from a beforeEach gives each test a fresh cache +export function clearQueryCache() { + runInApp(() => { + const queryCache = useQueryCache() + + queryCache.getEntries().forEach((entry) => { + queryCache.remove(entry) + }) + }) +} + +export function seedQueryData(key: EntryKey, data: unknown) { + runInApp(() => { + useQueryCache().setQueryData(key, data) + }) +} + +export function readQueryData(key: EntryKey): unknown { + return runInApp(() => useQueryCache().getQueryData(key)) +} + +// the api composables read the auth session and organization through +// useAuthSession's cache entries; seeding them fresh BEFORE a composable +// is created keeps the eager auth loads from firing and the seeds intact +export function seedAuthSession(userId: string) { + seedQueryData(["auth", "session"], { + data: { user: { id: userId } }, + error: null, + }) +} + +export function seedAuthOrganization(organizationId: string) { + seedQueryData(["auth", "organization"], { + data: { id: organizationId }, + error: null, + }) +} + +// asymmetric matchers are typed `any`, which the type-aware lint rejects +// inside object literals; typing them here keeps the assertions unchanged +export const ANY_STRING = staticExpect.any(String) as unknown as string +export const ANY_DATE = staticExpect.any(Date) as unknown as Date + +export function matchingString(pattern: RegExp): string { + return staticExpect.stringMatching(pattern) as string +} diff --git a/web/app/composables/api/useAuthAPI.nuxt.test.ts b/web/app/composables/api/useAuthAPI.nuxt.test.ts new file mode 100644 index 00000000..f0c59667 --- /dev/null +++ b/web/app/composables/api/useAuthAPI.nuxt.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, +} from "./test-helpers" +import useAuthAPI from "./useAuthAPI" + +function makeAuthAPI() { + return runInApp(() => useAuthAPI()) +} + +// creating the composable eagerly loads its query once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic +describe("useAuthAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("fetchAuthConfig", () => { + it("fetches the auth config", async ({ expect }) => { + const configCalls = mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/auth-config", + () => ({ methods: ["email-password", "github"] }), + ) + const api = makeAuthAPI() + + const result = await api.fetchAuthConfig.refresh() + + expect(result.data).toEqual({ methods: ["email-password", "github"] }) + expect(configCalls).toHaveLength(1) + }) + }) +}) diff --git a/web/app/composables/api/useDataSourceAPI.nuxt.test.ts b/web/app/composables/api/useDataSourceAPI.nuxt.test.ts new file mode 100644 index 00000000..80f66e36 --- /dev/null +++ b/web/app/composables/api/useDataSourceAPI.nuxt.test.ts @@ -0,0 +1,830 @@ +import { setResponseStatus } from "h3" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + ANY_DATE, + ANY_STRING, + clearQueryCache, + disposeMockEndpoints, + makeXid, + mockEndpoint, + readQueryData, + runInApp, + seedQueryData, +} from "./test-helpers" +import useDataSourceAPI from "./useDataSourceAPI" + +const DS_ID = makeXid("ds1") +const OTHER_DS_ID = makeXid("ds2") +const OPTIMISTIC_ID = "local-1" + +const LIST_KEY = ["data-sources"] as const +const LIST_URL = "/api/data-sources" +const DETAIL_URL = `${LIST_URL}/${DS_ID}` +const CONNECTION_URL = `${DETAIL_URL}/connection` +const QUERY_URL = `${DETAIL_URL}/query` + +function makeDataSourceAPI() { + return runInApp(() => useDataSourceAPI()) +} + +function detailKey(dataSourceId: string) { + return ["data-sources", dataSourceId] as const +} + +function makeDataSource(overrides: Partial = {}): DataSource { + return { + id: DS_ID, + name: "Prometheus", + type: DataSourceType.Prometheus, + url: "https://prom.test", + status: DataSourceStatus.Success, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: null, + ...overrides, + } +} + +function makeCreateRequest(): DataSourceCreateRequest { + return { + type: DataSourceType.Prometheus, + name: "New Prometheus", + url: "https://new-prom.test", + credentials: { username: "u1", password: "p1" }, + } +} + +function makeQueryParams(): GenericQueryParams { + return { + q: "up", + chartType: GenericQueryChartType.Line, + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + timeRangeKey: "24h", + } +} + +function makeMultiQueryParams(): GenericMultipleQueriesParams { + return { + queries: ["up", "down"], + chartType: GenericQueryChartType.Line, + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + timeRangeKey: "24h", + } +} + +function makeQueryResult(value: number): GenericQueryResult { + return { + status: GenericQueryResultStatus.Ok, + data: [{ labels: { job: "api" }, metrics: [[1767225600, value]] }], + } +} + +// creating the composable eagerly loads the data source list once (empty +// cache); refresh() joins that in-flight load instead of forcing a second +// request. Tests that are not about the list seed the list cache first so +// the eager load is skipped, and pin that with a zero-count assertion. +describe("useDataSourceAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("fetchDataSources", () => { + it("fetches the data sources", async ({ expect }) => { + const dataSource = makeDataSource() + const listCalls = mockEndpoint("GET", LIST_URL, () => [dataSource]) + const api = makeDataSourceAPI() + + const result = await api.fetchDataSources.refresh() + + expect(result.data).toEqual([dataSource]) + expect(listCalls).toHaveLength(1) + }) + + it("returns an empty list when the response body is null", async ({ + expect, + }) => { + const listCalls = mockEndpoint("GET", LIST_URL, () => null) + const api = makeDataSourceAPI() + + const result = await api.fetchDataSources.refresh() + + expect(result.data).toEqual([]) + expect(listCalls).toHaveLength(1) + }) + }) + + describe("useFetchDataSourceById", () => { + it("returns null without a data source id", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const detailCalls = mockEndpoint("GET", DETAIL_URL, () => + makeDataSource(), + ) + const api = makeDataSourceAPI() + const dataSource = runInApp(() => api.useFetchDataSourceById(null)) + + const result = await dataSource.refresh() + + expect(result.data).toBeNull() + expect(detailCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("fetches the data source by id", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const expected = makeDataSource() + const detailCalls = mockEndpoint("GET", DETAIL_URL, () => expected) + const api = makeDataSourceAPI() + const dataSource = runInApp(() => api.useFetchDataSourceById(DS_ID)) + + const result = await dataSource.refresh() + + expect(result.data).toEqual(expected) + expect(detailCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("testDataSourceConnection", () => { + it("returns null without a data source id", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const connectionCalls = mockEndpoint("GET", CONNECTION_URL, () => ({ + status: "success", + })) + const api = makeDataSourceAPI() + const connection = runInApp(() => api.testDataSourceConnection(null)) + + const result = await connection.refresh() + + expect(result.data).toBeNull() + expect(connectionCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("fetches the connection status of the data source", async ({ + expect, + }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const connectionCalls = mockEndpoint("GET", CONNECTION_URL, () => ({ + status: "success", + })) + const api = makeDataSourceAPI() + // staleTime is 0 for this query, so refresh() must join the + // eager load immediately — an await in between would let it + // finish and go stale, forcing a second request + const connection = runInApp(() => api.testDataSourceConnection(DS_ID)) + + const result = await connection.refresh() + + expect(result.data).toEqual({ status: "success" }) + expect(connectionCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("createDataSource", () => { + it("inserts the data source optimistically and refreshes the list on success", async ({ + expect, + }) => { + const request = makeCreateRequest() + const existing = makeDataSource() + const created = makeDataSource({ + id: OTHER_DS_ID, + name: request.name, + url: request.url, + }) + seedQueryData(LIST_KEY, [existing]) + const listCalls = mockEndpoint("GET", LIST_URL, () => [created, existing]) + + let resolveCreate: (value: unknown) => void = () => undefined + let createReached: () => void = () => undefined + const createReachedSignal = new Promise((resolve) => { + createReached = resolve + }) + const createCalls = mockEndpoint("POST", LIST_URL, () => { + createReached() + + return new Promise((resolve) => { + resolveCreate = resolve + }) + }) + const api = makeDataSourceAPI() + + const pending = api.createDataSource.mutateAsync(request) + await createReachedSignal + + // the optimistic insert is prepended with a local nanoid id and + // a pending status until the server confirms + expect(readQueryData(LIST_KEY)).toEqual([ + { + id: ANY_STRING, + name: request.name, + type: request.type, + url: request.url, + status: DataSourceStatus.LocalOptimisticInsert, + createdAt: ANY_DATE, + updatedAt: null, + }, + existing, + ]) + resolveCreate(created) + + await pending + + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.body).toEqual(request) + // the success invalidation refetches the active list query + expect(listCalls).toHaveLength(1) + expect(api.fetchDataSources.data.value).toEqual([created, existing]) + }) + + it("rolls back the optimistic insert when the request fails", async ({ + expect, + }) => { + const existing = makeDataSource() + seedQueryData(LIST_KEY, [existing]) + const listCalls = mockEndpoint("GET", LIST_URL, () => [existing]) + const createCalls = mockEndpoint("POST", LIST_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeDataSourceAPI() + + await expect( + api.createDataSource.mutateAsync(makeCreateRequest()), + ).rejects.toThrow() + + expect(createCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(api.fetchDataSources.data.value).toEqual([existing]) + }) + + it("skips the rollback when the list changed after the optimistic insert", async ({ + expect, + }) => { + const existing = makeDataSource() + const divergent = makeDataSource({ id: OTHER_DS_ID, name: "Divergent" }) + seedQueryData(LIST_KEY, [existing]) + const listCalls = mockEndpoint("GET", LIST_URL, () => [existing]) + + let rejectCreate: (err: unknown) => void = () => undefined + let createReached: () => void = () => undefined + const createReachedSignal = new Promise((resolve) => { + createReached = resolve + }) + const createCalls = mockEndpoint("POST", LIST_URL, () => { + createReached() + + return new Promise((_resolve, reject) => { + rejectCreate = reject + }) + }) + const api = makeDataSourceAPI() + + const pending = api.createDataSource.mutateAsync(makeCreateRequest()) + await createReachedSignal + + // divergent data written after the optimistic insert must + // survive the failure + seedQueryData(LIST_KEY, [divergent]) + rejectCreate(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + + expect(createCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(api.fetchDataSources.data.value).toEqual([divergent]) + }) + }) + + describe("updateDataSource", () => { + it("sends the update without touching the caches for an optimistic-insert id", async ({ + expect, + }) => { + const optimistic = makeDataSource({ + id: OPTIMISTIC_ID, + status: DataSourceStatus.LocalOptimisticInsert, + }) + seedQueryData(LIST_KEY, [optimistic]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const updateCalls = mockEndpoint( + "PUT", + `${LIST_URL}/${OPTIMISTIC_ID}`, + () => makeDataSource(), + ) + const api = makeDataSourceAPI() + + await api.updateDataSource.mutateAsync({ + dataSourceId: OPTIMISTIC_ID, + req: { name: "Renamed", url: "https://renamed.test" }, + }) + + expect(updateCalls).toHaveLength(1) + expect(updateCalls[0]?.body).toEqual({ + name: "Renamed", + url: "https://renamed.test", + }) + expect(listCalls).toHaveLength(0) + expect(api.fetchDataSources.data.value).toEqual([optimistic]) + }) + + it("updates the caches optimistically and refreshes them on success", async ({ + expect, + }) => { + const target = makeDataSource() + const other = makeDataSource({ id: OTHER_DS_ID, name: "Other" }) + const updated = makeDataSource({ + name: "Renamed", + url: "https://renamed.test", + updatedAt: "2026-02-01T00:00:00.000Z", + }) + seedQueryData(LIST_KEY, [target, other]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [updated, other]) + const detailCalls = mockEndpoint("GET", DETAIL_URL, () => updated) + + let resolveUpdate: (value: unknown) => void = () => undefined + let updateReached: () => void = () => undefined + const updateReachedSignal = new Promise((resolve) => { + updateReached = resolve + }) + const updateCalls = mockEndpoint("PUT", DETAIL_URL, () => { + updateReached() + + return new Promise((resolve) => { + resolveUpdate = resolve + }) + }) + const api = makeDataSourceAPI() + const dataSource = runInApp(() => api.useFetchDataSourceById(DS_ID)) + + const pending = api.updateDataSource.mutateAsync({ + dataSourceId: DS_ID, + req: { name: "Renamed", url: "https://renamed.test" }, + }) + await updateReachedSignal + + expect(readQueryData(LIST_KEY)).toEqual([ + { + ...target, + name: "Renamed", + url: "https://renamed.test", + updatedAt: ANY_DATE, + }, + other, + ]) + expect(readQueryData(detailKey(DS_ID))).toEqual({ + ...target, + name: "Renamed", + url: "https://renamed.test", + updatedAt: ANY_DATE, + }) + resolveUpdate(updated) + + await pending + + expect(updateCalls).toHaveLength(1) + expect(updateCalls[0]?.body).toEqual({ + name: "Renamed", + url: "https://renamed.test", + }) + // key filters prefix-match, so invalidating the list key also + // refetches the active detail query once, and the explicit detail + // invalidation refetches it a second time + expect(listCalls).toHaveLength(1) + expect(detailCalls).toHaveLength(2) + expect(api.fetchDataSources.data.value).toEqual([updated, other]) + expect(dataSource.data.value).toEqual(updated) + }) + + it("updates only the fields present in the request", async ({ expect }) => { + const target = makeDataSource() + seedQueryData(LIST_KEY, [target]) + const listCalls = mockEndpoint("GET", LIST_URL, () => [target]) + + let resolveUpdate: (value: unknown) => void = () => undefined + let updateReached: () => void = () => undefined + const updateReachedSignal = new Promise((resolve) => { + updateReached = resolve + }) + const updateCalls = mockEndpoint("PUT", DETAIL_URL, () => { + updateReached() + + return new Promise((resolve) => { + resolveUpdate = resolve + }) + }) + const api = makeDataSourceAPI() + + const pending = api.updateDataSource.mutateAsync({ + dataSourceId: DS_ID, + req: { name: "Renamed" }, + }) + await updateReachedSignal + + // url is not part of the request, so it keeps its cached value + expect(readQueryData(LIST_KEY)).toEqual([ + { ...target, name: "Renamed", updatedAt: ANY_DATE }, + ]) + resolveUpdate(makeDataSource({ name: "Renamed" })) + + await pending + + expect(updateCalls).toHaveLength(1) + expect(updateCalls[0]?.body).toEqual({ name: "Renamed" }) + // the detail cache was empty, so only the list is invalidated + expect(listCalls).toHaveLength(1) + }) + + it("rolls back the list and detail caches when the request fails", async ({ + expect, + }) => { + const target = makeDataSource() + seedQueryData(LIST_KEY, [target]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [target]) + const updateCalls = mockEndpoint("PUT", DETAIL_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeDataSourceAPI() + + await expect( + api.updateDataSource.mutateAsync({ + dataSourceId: DS_ID, + req: { name: "Renamed", url: "https://renamed.test" }, + }), + ).rejects.toThrow() + + expect(updateCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(readQueryData(LIST_KEY)).toEqual([target]) + expect(readQueryData(detailKey(DS_ID))).toEqual(target) + }) + + it("skips the list rollback when the list changed after the optimistic update", async ({ + expect, + }) => { + const target = makeDataSource() + const divergent = makeDataSource({ id: OTHER_DS_ID, name: "Divergent" }) + seedQueryData(LIST_KEY, [target]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [target]) + + let rejectUpdate: (err: unknown) => void = () => undefined + let updateReached: () => void = () => undefined + const updateReachedSignal = new Promise((resolve) => { + updateReached = resolve + }) + const updateCalls = mockEndpoint("PUT", DETAIL_URL, () => { + updateReached() + + return new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + }) + const api = makeDataSourceAPI() + + const pending = api.updateDataSource.mutateAsync({ + dataSourceId: DS_ID, + req: { name: "Renamed", url: "https://renamed.test" }, + }) + await updateReachedSignal + + // the diverged list must survive the failure, while the + // untouched detail cache is still rolled back + seedQueryData(LIST_KEY, [divergent]) + rejectUpdate(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + + expect(updateCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(readQueryData(LIST_KEY)).toEqual([divergent]) + expect(readQueryData(detailKey(DS_ID))).toEqual(target) + }) + }) + + describe("deleteDataSource", () => { + it("sends the delete without touching the caches for an optimistic-insert id", async ({ + expect, + }) => { + const optimistic = makeDataSource({ + id: OPTIMISTIC_ID, + status: DataSourceStatus.LocalOptimisticInsert, + }) + seedQueryData(LIST_KEY, [optimistic]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const deleteCalls = mockEndpoint( + "DELETE", + `${LIST_URL}/${OPTIMISTIC_ID}`, + () => null, + ) + const api = makeDataSourceAPI() + + await api.deleteDataSource.mutateAsync(OPTIMISTIC_ID) + + expect(deleteCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(api.fetchDataSources.data.value).toEqual([optimistic]) + }) + + it("removes the data source optimistically and refreshes the caches on success", async ({ + expect, + }) => { + const target = makeDataSource() + const other = makeDataSource({ id: OTHER_DS_ID, name: "Other" }) + seedQueryData(LIST_KEY, [target, other]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [other]) + const detailCalls = mockEndpoint("GET", DETAIL_URL, () => target) + + let resolveDelete: (value: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + const deleteCalls = mockEndpoint("DELETE", DETAIL_URL, () => { + deleteReached() + + return new Promise((resolve) => { + resolveDelete = resolve + }) + }) + const api = makeDataSourceAPI() + const dataSource = runInApp(() => api.useFetchDataSourceById(DS_ID)) + + const pending = api.deleteDataSource.mutateAsync(DS_ID) + await deleteReachedSignal + + expect(readQueryData(LIST_KEY)).toEqual([other]) + expect(readQueryData(detailKey(DS_ID))).toBeUndefined() + resolveDelete(null) + + await pending + + expect(deleteCalls).toHaveLength(1) + // key filters prefix-match, so invalidating the list key also + // refetches the active detail query once, and the explicit detail + // invalidation refetches it a second time + expect(listCalls).toHaveLength(1) + expect(detailCalls).toHaveLength(2) + expect(api.fetchDataSources.data.value).toEqual([other]) + expect(dataSource.data.value).toEqual(target) + }) + + it("rolls back the list and detail caches when the request fails", async ({ + expect, + }) => { + const target = makeDataSource() + seedQueryData(LIST_KEY, [target]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [target]) + const deleteCalls = mockEndpoint("DELETE", DETAIL_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeDataSourceAPI() + + await expect(api.deleteDataSource.mutateAsync(DS_ID)).rejects.toThrow() + + expect(deleteCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(readQueryData(LIST_KEY)).toEqual([target]) + expect(readQueryData(detailKey(DS_ID))).toEqual(target) + }) + + it("skips the rollback when the caches changed after the optimistic removal", async ({ + expect, + }) => { + const target = makeDataSource() + const divergent = makeDataSource({ id: OTHER_DS_ID, name: "Divergent" }) + const divergentDetail = makeDataSource({ name: "Divergent Detail" }) + seedQueryData(LIST_KEY, [target]) + seedQueryData(detailKey(DS_ID), target) + const listCalls = mockEndpoint("GET", LIST_URL, () => [target]) + + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + const deleteCalls = mockEndpoint("DELETE", DETAIL_URL, () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeDataSourceAPI() + + const pending = api.deleteDataSource.mutateAsync(DS_ID) + await deleteReachedSignal + + // divergent data written after the optimistic removal must + // survive the failure in both caches + seedQueryData(LIST_KEY, [divergent]) + seedQueryData(detailKey(DS_ID), divergentDetail) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + + expect(deleteCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(readQueryData(LIST_KEY)).toEqual([divergent]) + expect(readQueryData(detailKey(DS_ID))).toEqual(divergentDetail) + }) + }) + + describe("useGenericQuery", () => { + it("returns null without query parameters", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const queryCalls = mockEndpoint("GET", QUERY_URL, () => + makeQueryResult(1), + ) + const api = makeDataSourceAPI() + const query = runInApp(() => api.useGenericQuery(DS_ID, null)) + + const result = await query.refresh() + + expect(result.data).toBeNull() + expect(queryCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("fetches the query result with the time range parameters", async ({ + expect, + }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const expected = makeQueryResult(1) + const queryCalls = mockEndpoint("GET", QUERY_URL, () => expected) + const api = makeDataSourceAPI() + const query = runInApp(() => + api.useGenericQuery(DS_ID, makeQueryParams()), + ) + + const result = await query.refresh() + + expect(result.data).toEqual(expected) + expect(queryCalls).toHaveLength(1) + expect(queryCalls[0]?.query).toEqual({ + q: "up", + chartType: "line_chart", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + }) + expect(listCalls).toHaveLength(0) + }) + + it("returns a query-error result when the api reports a query error", async ({ + expect, + }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const queryCalls = mockEndpoint("GET", QUERY_URL, (_call, event) => { + setResponseStatus(event, 400) + + return { message: "bad query", code: "query.error" } + }) + const api = makeDataSourceAPI() + const query = runInApp(() => + api.useGenericQuery(DS_ID, makeQueryParams()), + ) + + const result = await query.refresh() + + expect(result.data).toEqual({ + status: GenericQueryResultStatus.QueryError, + data: [], + queryErrorMessage: "bad query", + }) + expect(result.error).toBeNull() + expect(queryCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("reports a non-query error through the error state", async ({ + expect, + }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const successCalls = mockEndpoint("GET", QUERY_URL, () => + makeQueryResult(1), + ) + const api = makeDataSourceAPI() + const query = runInApp(() => + api.useGenericQuery(DS_ID, makeQueryParams()), + ) + // a failing query cannot dedupe the eager creation load into the + // act, so the entry is warmed with a success first and the failure + // is a single forced refetch through a later-wins registration + await query.refresh() + const failedCalls = mockEndpoint("GET", QUERY_URL, () => { + throw createError({ statusCode: 500 }) + }) + + const result = await query.refetch() + + expect(result.error).toMatchObject({ statusCode: 500 }) + // a non-"query.error" failure keeps the previously fetched result + // instead of mapping to a QueryError result + expect(query.data.value).toEqual(makeQueryResult(1)) + expect(successCalls).toHaveLength(1) + // ofetch retries a failed GET once by default on a 500 response + expect(failedCalls).toHaveLength(2) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("useMultipleGenericQueries", () => { + it("returns no results without query parameters", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const queryCalls = mockEndpoint("GET", QUERY_URL, () => + makeQueryResult(1), + ) + const api = makeDataSourceAPI() + const queries = runInApp(() => + api.useMultipleGenericQueries(DS_ID, null, false), + ) + + const result = await queries.refresh() + + expect(result.data).toEqual([]) + expect(queryCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("fetches one result per query in order", async ({ expect }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const upResult = makeQueryResult(1) + const downResult = makeQueryResult(2) + const queryCalls = mockEndpoint("GET", QUERY_URL, (call) => + call.query.q === "up" ? upResult : downResult, + ) + const api = makeDataSourceAPI() + const queries = runInApp(() => + api.useMultipleGenericQueries(DS_ID, makeMultiQueryParams(), false), + ) + + const result = await queries.refresh() + + expect(result.data).toEqual([upResult, downResult]) + expect(queryCalls).toHaveLength(2) + expect(queryCalls[0]?.query).toEqual({ + q: "up", + chartType: "line_chart", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + }) + expect(queryCalls[1]?.query).toEqual({ + q: "down", + chartType: "line_chart", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + }) + expect(listCalls).toHaveLength(0) + }) + + it("turns a failing query into a query-error result while the others succeed", async ({ + expect, + }) => { + seedQueryData(LIST_KEY, []) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const upResult = makeQueryResult(1) + const queryCalls = mockEndpoint("GET", QUERY_URL, (call, event) => { + if (call.query.q === "down") { + setResponseStatus(event, 400) + + return { message: "down failed", code: "query.error" } + } + + return upResult + }) + const api = makeDataSourceAPI() + const queries = runInApp(() => + api.useMultipleGenericQueries(DS_ID, makeMultiQueryParams(), false), + ) + + const result = await queries.refresh() + + expect(result.data).toEqual([ + upResult, + { + status: GenericQueryResultStatus.QueryError, + data: [], + queryErrorMessage: "down failed", + }, + ]) + expect(result.error).toBeNull() + expect(queryCalls).toHaveLength(2) + expect(listCalls).toHaveLength(0) + }) + }) +}) diff --git a/web/app/composables/api/useDocumentAPI.nuxt.test.ts b/web/app/composables/api/useDocumentAPI.nuxt.test.ts new file mode 100644 index 00000000..b28412bf --- /dev/null +++ b/web/app/composables/api/useDocumentAPI.nuxt.test.ts @@ -0,0 +1,1773 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import type { DocumentTreeElement } from "~/utils" +import { + ANY_DATE, + ANY_STRING, + clearQueryCache, + disposeMockEndpoints, + makeXid, + matchingString, + mockDeferredEndpoint, + mockEndpoint, + readQueryData, + runInApp, + seedAuthOrganization, + seedAuthSession, + seedQueryData, +} from "./test-helpers" +import useDocumentAPI from "./useDocumentAPI" + +const DOC_ID = makeXid("doc1") +const DOC_ID_2 = makeXid("doc2") +const CHILD_ID = makeXid("chld") +const BRANCH_ID = makeXid("brn1") +const BRANCH_ID_2 = makeXid("brn2") +const USER_ID = makeXid("usr1") +const USER_ID_2 = makeXid("usr2") +const ORG_ID = makeXid("org1") +const SHORT_ID = "nanoid-1" + +const TREE_KEY = ["documents", "tree"] as const +const BRANCHES_KEY = ["documents", DOC_ID, "branches"] as const +const REVIEWERS_KEY = [ + "documents", + DOC_ID, + "branches", + BRANCH_ID, + "reviewers", +] as const + +const TREE_URL = "/api/documents/tree" +const MERGE_URL = `http://test.local/auth-realtime/api/documents/${DOC_ID}/merge` + +function makeDocumentAPI() { + return runInApp(() => useDocumentAPI()) +} + +function seedAuth() { + seedAuthSession(USER_ID) + seedAuthOrganization(ORG_ID) +} + +function makeElem( + id: string, + name: string, + overrides: Partial = {}, +): DocumentTreeElement { + return { + id, + documentName: name, + icon: "icon-a", + protected: false, + children: null, + ...overrides, + } +} + +function makeBranch(branchId: string, branchName: string) { + return { + branchId, + branchName, + documentName: "Doc A", + icon: "icon-a", + protected: false, + default: branchName === "main", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } +} + +function makeReviewer(userId: string, currentlyApproved: boolean) { + return { + branchId: BRANCH_ID, + userId, + organizationId: ORG_ID, + currentlyApproved, + previouslyApproved: false, + } +} + +// creating the composable eagerly loads the tree query once when its cache +// entry is empty; the tests either seed the tree first (the seed is fresh, +// so the eager load is skipped) or use refresh() as the act, which joins +// the in-flight eager load — both keep the call accounting deterministic +describe("useDocumentAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("fetchDocumentTree", () => { + it("fetches the document tree", async ({ expect }) => { + const tree = [makeElem(DOC_ID, "Doc A")] + const treeCalls = mockEndpoint("GET", TREE_URL, () => tree) + const api = makeDocumentAPI() + + const result = await api.fetchDocumentTree.refresh() + + expect(result.data).toEqual(tree) + expect(treeCalls).toHaveLength(1) + }) + }) + + describe("updateDocumentTree", () => { + it("rejects for a non-xid document id without touching anything", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const putCalls = mockEndpoint("PUT", TREE_URL, () => ({})) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + // onMutate bails out without a context, so the mutation cannot + // read finalReq off it and dies before reaching the network + await expect( + api.updateDocumentTree.mutateAsync({ + id: SHORT_ID, + parentId: null, + insertBeforeId: null, + }), + ).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(putCalls).toHaveLength(0) + expect(treeCalls).toHaveLength(0) + }) + + it("rejects when the document is not in the tree", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const putCalls = mockEndpoint("PUT", TREE_URL, () => ({})) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await expect( + api.updateDocumentTree.mutateAsync({ + id: DOC_ID_2, + parentId: null, + insertBeforeId: null, + }), + ).rejects.toThrow("invalid document tree update data") + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(putCalls).toHaveLength(0) + expect(treeCalls).toHaveLength(0) + }) + + it("moves a root document before a sibling and refreshes the tree", async ({ + expect, + }) => { + const serverTree = [ + makeElem(DOC_ID_2, "Doc B"), + makeElem(DOC_ID, "Doc A"), + ] + const treeCalls = mockEndpoint("GET", TREE_URL, () => serverTree) + const put = mockDeferredEndpoint("PUT", TREE_URL) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B"), + ]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentTree.mutateAsync({ + id: DOC_ID_2, + parentId: null, + insertBeforeId: DOC_ID, + }) + await put.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID_2, "Doc B"), + makeElem(DOC_ID, "Doc A"), + ]) + put.resolve(null) + + await pending + + expect(put.calls).toHaveLength(1) + expect(put.calls[0]?.body).toEqual({ + id: DOC_ID_2, + parentId: null, + sortIndex: 0, + }) + // the success invalidation refetches the active tree query + expect(treeCalls).toHaveLength(1) + expect(api.fetchDocumentTree.data.value).toEqual(serverTree) + }) + + it("moves a root document into a parent", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const put = mockDeferredEndpoint("PUT", TREE_URL) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B", { + children: [makeElem(CHILD_ID, "Child")], + }), + ]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentTree.mutateAsync({ + id: DOC_ID, + parentId: DOC_ID_2, + insertBeforeId: null, + }) + await put.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID_2, "Doc B", { + children: [makeElem(DOC_ID, "Doc A"), makeElem(CHILD_ID, "Child")], + }), + ]) + put.resolve(null) + + await pending + + expect(put.calls[0]?.body).toEqual({ + id: DOC_ID, + parentId: DOC_ID_2, + sortIndex: 0, + }) + expect(treeCalls).toHaveLength(1) + }) + + it("moves the only child of a parent to the root", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const put = mockDeferredEndpoint("PUT", TREE_URL) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID_2, "Doc B", { + children: [makeElem(CHILD_ID, "Child")], + }), + ]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentTree.mutateAsync({ + id: CHILD_ID, + parentId: null, + insertBeforeId: null, + }) + await put.reached + + // the emptied children key is removed from the old parent + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(CHILD_ID, "Child"), + makeElem(DOC_ID_2, "Doc B", { children: undefined }), + ]) + put.resolve(null) + + await pending + + expect(put.calls[0]?.body).toEqual({ + id: CHILD_ID, + parentId: null, + sortIndex: 0, + }) + expect(treeCalls).toHaveLength(1) + }) + + it("rolls back the tree when the request fails", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + mockEndpoint("PUT", TREE_URL, () => { + throw createError({ statusCode: 500 }) + }) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B"), + ]) + const api = makeDocumentAPI() + + await expect( + api.updateDocumentTree.mutateAsync({ + id: DOC_ID_2, + parentId: null, + insertBeforeId: DOC_ID, + }), + ).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B"), + ]) + expect(treeCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic move", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const put = mockDeferredEndpoint("PUT", TREE_URL) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B"), + ]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentTree.mutateAsync({ + id: DOC_ID_2, + parentId: null, + insertBeforeId: DOC_ID, + }) + await put.reached + + // divergent data written after the optimistic move must survive + // the failure + seedQueryData(TREE_KEY, [makeElem(CHILD_ID, "Divergent")]) + put.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(TREE_KEY)).toEqual([makeElem(CHILD_ID, "Divergent")]) + }) + }) + + describe("createDocument", () => { + it("skips the local optimistic insert when asked", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint("POST", "/api/documents") + seedQueryData(TREE_KEY, []) + const api = makeDocumentAPI() + + const pending = api.createDocument.mutateAsync({ + name: "Doc A", + icon: "icon-a", + parentId: null, + skipLocalOptimisticInsert: true, + }) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([]) + post.resolve({ id: DOC_ID }) + + await pending + + // the local-only flag is stripped from the request body + expect(post.calls[0]?.body).toEqual({ + name: "Doc A", + icon: "icon-a", + parentId: null, + }) + expect(treeCalls).toHaveLength(1) + }) + + it("bails out for a non-xid parent but still refreshes the tree", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const postCalls = mockEndpoint("POST", "/api/documents", () => ({ + id: DOC_ID, + })) + seedQueryData(TREE_KEY, []) + const api = makeDocumentAPI() + + await api.createDocument.mutateAsync({ + name: "Doc A", + icon: "icon-a", + parentId: SHORT_ID, + }) + + expect(readQueryData(TREE_KEY)).toEqual([]) + expect(postCalls).toHaveLength(0) + // onSuccess is not guarded, so the tree still refetches + expect(treeCalls).toHaveLength(1) + }) + + it("inserts the document into an empty tree", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint("POST", "/api/documents") + seedQueryData(TREE_KEY, []) + const api = makeDocumentAPI() + + const pending = api.createDocument.mutateAsync({ + name: "Doc A", + icon: "icon-a", + parentId: null, + }) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + { + id: ANY_STRING, + documentName: "Doc A", + icon: "icon-a", + children: null, + protected: false, + localOptimisticInsert: true, + }, + ]) + post.resolve({ id: DOC_ID }) + + await pending + + expect(post.calls[0]?.body).toEqual({ + name: "Doc A", + icon: "icon-a", + parentId: null, + }) + expect(treeCalls).toHaveLength(1) + }) + + it("prepends the document at the root of a non-empty tree", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint("POST", "/api/documents") + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.createDocument.mutateAsync({ + name: "Doc B", + icon: "icon-b", + parentId: null, + }) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + expect.objectContaining({ + documentName: "Doc B", + localOptimisticInsert: true, + }), + makeElem(DOC_ID, "Doc A"), + ]) + post.resolve({ id: DOC_ID_2 }) + + await pending + }) + + it("prepends the document under its parent", async ({ expect }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint("POST", "/api/documents") + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A", { children: [makeElem(CHILD_ID, "Child")] }), + ]) + const api = makeDocumentAPI() + + const pending = api.createDocument.mutateAsync({ + name: "Doc B", + icon: "icon-b", + parentId: DOC_ID, + }) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A", { + children: [ + expect.objectContaining({ + documentName: "Doc B", + localOptimisticInsert: true, + }), + makeElem(CHILD_ID, "Child"), + ], + }), + ]) + post.resolve({ id: DOC_ID_2 }) + + await pending + }) + + it("rolls back the tree when the request fails", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + mockEndpoint("POST", "/api/documents", () => { + throw createError({ statusCode: 500 }) + }) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await expect( + api.createDocument.mutateAsync({ + name: "Doc B", + icon: "icon-b", + parentId: null, + }), + ).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(treeCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic insert", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint("POST", "/api/documents") + seedQueryData(TREE_KEY, []) + const api = makeDocumentAPI() + + const pending = api.createDocument.mutateAsync({ + name: "Doc A", + icon: "icon-a", + parentId: null, + }) + await post.reached + + seedQueryData(TREE_KEY, [makeElem(CHILD_ID, "Divergent")]) + post.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(TREE_KEY)).toEqual([makeElem(CHILD_ID, "Divergent")]) + }) + }) + + describe("deleteDocument", () => { + it("bails out for a non-xid id but still refreshes the tree", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => [ + makeElem(DOC_ID, "Doc A"), + ]) + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${SHORT_ID}`, + () => null, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await api.deleteDocument.mutateAsync(SHORT_ID) + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(deleteCalls).toHaveLength(0) + // onSuccess is not guarded, so the tree still refetches + expect(treeCalls).toHaveLength(1) + }) + + it("removes a root document optimistically", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const del = mockDeferredEndpoint("DELETE", `/api/documents/${DOC_ID}`) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A"), + makeElem(DOC_ID_2, "Doc B"), + ]) + const api = makeDocumentAPI() + + const pending = api.deleteDocument.mutateAsync(DOC_ID) + await del.reached + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID_2, "Doc B")]) + del.resolve(null) + + await pending + + expect(del.calls).toHaveLength(1) + expect(treeCalls).toHaveLength(1) + }) + + it("removes a nested document and drops the emptied children key", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const del = mockDeferredEndpoint("DELETE", `/api/documents/${CHILD_ID}`) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A", { children: [makeElem(CHILD_ID, "Child")] }), + ]) + const api = makeDocumentAPI() + + const pending = api.deleteDocument.mutateAsync(CHILD_ID) + await del.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A", { children: undefined }), + ]) + del.resolve(null) + + await pending + }) + + it("rolls back the tree when the request fails", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + mockEndpoint("DELETE", `/api/documents/${DOC_ID}`, () => { + throw createError({ statusCode: 500 }) + }) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await expect(api.deleteDocument.mutateAsync(DOC_ID)).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(treeCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const del = mockDeferredEndpoint("DELETE", `/api/documents/${DOC_ID}`) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.deleteDocument.mutateAsync(DOC_ID) + await del.reached + + seedQueryData(TREE_KEY, [makeElem(CHILD_ID, "Divergent")]) + del.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(TREE_KEY)).toEqual([makeElem(CHILD_ID, "Divergent")]) + }) + }) + + describe("updateDocumentTreeElementCache", () => { + it("does nothing without a cached tree", ({ expect }) => { + const api = makeDocumentAPI() + + api.updateDocumentTreeElementCache(DOC_ID, { + name: "Renamed", + icon: "icon-b", + protected: true, + }) + + expect(readQueryData(TREE_KEY)).toBeUndefined() + }) + + it("updates the matching nested element", ({ expect }) => { + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A", { children: [makeElem(CHILD_ID, "Child")] }), + ]) + const api = makeDocumentAPI() + + api.updateDocumentTreeElementCache(CHILD_ID, { + name: "Renamed", + icon: "icon-b", + protected: true, + }) + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A", { + children: [ + makeElem(CHILD_ID, "Renamed", { icon: "icon-b", protected: true }), + ], + }), + ]) + }) + }) + + describe("duplicateDocument", () => { + it("bails out for a non-xid id but still refreshes the tree", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => [ + makeElem(DOC_ID, "Doc A"), + ]) + const postCalls = mockEndpoint( + "POST", + `/api/documents/${SHORT_ID}/duplicate`, + () => ({ id: DOC_ID }), + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await api.duplicateDocument.mutateAsync(SHORT_ID) + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(postCalls).toHaveLength(0) + expect(treeCalls).toHaveLength(1) + }) + + it("inserts a timestamped copy next to the source document", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${DOC_ID}/duplicate`, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A", { icon: "icon-src" })]) + const api = makeDocumentAPI() + + const pending = api.duplicateDocument.mutateAsync(DOC_ID) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + { + id: ANY_STRING, + documentName: matchingString( + /^Doc A \(\d{4} \w{3}\. \d{2} \d{2}:\d{2}\)$/, + ), + icon: "icon-src", + children: null, + protected: false, + localOptimisticInsert: true, + }, + makeElem(DOC_ID, "Doc A", { icon: "icon-src" }), + ]) + post.resolve({ id: DOC_ID_2 }) + + await pending + + expect(post.calls).toHaveLength(1) + expect(treeCalls).toHaveLength(1) + }) + + it("inserts the copy under the source document's parent", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${CHILD_ID}/duplicate`, + ) + seedQueryData(TREE_KEY, [ + makeElem(DOC_ID, "Doc A", { children: [makeElem(CHILD_ID, "Child")] }), + ]) + const api = makeDocumentAPI() + + const pending = api.duplicateDocument.mutateAsync(CHILD_ID) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A", { + children: [ + expect.objectContaining({ + documentName: matchingString(/^Child \(/), + localOptimisticInsert: true, + }), + makeElem(CHILD_ID, "Child"), + ], + }), + ]) + post.resolve({ id: DOC_ID_2 }) + + await pending + }) + + it("falls back to a default name when the source is not cached", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${DOC_ID_2}/duplicate`, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.duplicateDocument.mutateAsync(DOC_ID_2) + await post.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + expect.objectContaining({ + documentName: matchingString(/^New Document \(/), + icon: "mingcute:document-2-fill", + localOptimisticInsert: true, + }), + makeElem(DOC_ID, "Doc A"), + ]) + post.resolve({ id: DOC_ID_2 }) + + await pending + }) + + it("rolls back the tree when the request fails", async ({ expect }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + mockEndpoint("POST", `/api/documents/${DOC_ID}/duplicate`, () => { + throw createError({ statusCode: 500 }) + }) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await expect(api.duplicateDocument.mutateAsync(DOC_ID)).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(treeCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic insert", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${DOC_ID}/duplicate`, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.duplicateDocument.mutateAsync(DOC_ID) + await post.reached + + seedQueryData(TREE_KEY, [makeElem(CHILD_ID, "Divergent")]) + post.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(TREE_KEY)).toEqual([makeElem(CHILD_ID, "Divergent")]) + }) + }) + + describe("searchDocuments", () => { + it("searches with the encoded query", async ({ expect }) => { + const results = [{ id: "b1", documentId: DOC_ID }] + const searchCalls = mockEndpoint( + "GET", + "/api/documents/search", + () => results, + ) + const api = makeDocumentAPI() + + const result = await api.searchDocuments("a b/c") + + expect(result).toEqual(results) + expect(searchCalls).toHaveLength(1) + expect(searchCalls[0]?.query).toEqual({ q: "a b/c" }) + }) + }) + + describe("updateDocumentBranch", () => { + it("bails out for a non-xid document id without a request", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => [ + makeElem(SHORT_ID, "Doc A"), + ]) + const putCalls = mockEndpoint( + "PUT", + `/api/documents/${SHORT_ID}/branches/${BRANCH_ID}`, + () => null, + ) + seedQueryData(TREE_KEY, [makeElem(SHORT_ID, "Doc A")]) + const api = makeDocumentAPI() + + await api.updateDocumentBranch.mutateAsync({ + id: SHORT_ID, + branchId: BRANCH_ID, + protectedMode: true, + }) + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(SHORT_ID, "Doc A")]) + expect(putCalls).toHaveLength(0) + // onSuccess is not guarded, so the tree still refetches + expect(treeCalls).toHaveLength(1) + }) + + it("sends the request for a non-xid branch id without an optimistic update", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => [makeElem(DOC_ID, "Doc A")]) + const putCalls = mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${SHORT_ID}`, + () => null, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await api.updateDocumentBranch.mutateAsync({ + id: DOC_ID, + branchId: SHORT_ID, + protectedMode: true, + }) + + // the mutation only guards the document id, so the request still + // goes out while the optimistic tree update is skipped + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + expect(putCalls).toHaveLength(1) + }) + + it("flips the protected flag optimistically and refreshes the tree", async ({ + expect, + }) => { + const treeCalls = mockEndpoint("GET", TREE_URL, () => []) + const put = mockDeferredEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}`, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentBranch.mutateAsync({ + id: DOC_ID, + branchId: BRANCH_ID, + protectedMode: true, + }) + await put.reached + + expect(readQueryData(TREE_KEY)).toEqual([ + makeElem(DOC_ID, "Doc A", { protected: true }), + ]) + put.resolve(null) + + await pending + + expect(put.calls).toHaveLength(1) + expect(put.calls[0]?.body).toEqual({ protected: true }) + expect(treeCalls).toHaveLength(1) + }) + + it("rolls back the tree when the request fails", async ({ expect }) => { + mockEndpoint("GET", TREE_URL, () => []) + mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + await expect( + api.updateDocumentBranch.mutateAsync({ + id: DOC_ID, + branchId: BRANCH_ID, + protectedMode: true, + }), + ).rejects.toThrow() + + expect(readQueryData(TREE_KEY)).toEqual([makeElem(DOC_ID, "Doc A")]) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + mockEndpoint("GET", TREE_URL, () => []) + const put = mockDeferredEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}`, + ) + seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")]) + const api = makeDocumentAPI() + + const pending = api.updateDocumentBranch.mutateAsync({ + id: DOC_ID, + branchId: BRANCH_ID, + protectedMode: true, + }) + await put.reached + + seedQueryData(TREE_KEY, [makeElem(CHILD_ID, "Divergent")]) + put.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(TREE_KEY)).toEqual([makeElem(CHILD_ID, "Divergent")]) + }) + }) + + describe("createDocumentBranch", () => { + it("bails out for a non-xid source branch", async ({ expect }) => { + const postCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches`, + () => ({}), + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await api.createDocumentBranch.mutateAsync({ + docId: DOC_ID, + req: { branch: "draft", sourceBranchId: SHORT_ID }, + }) + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + expect(postCalls).toHaveLength(0) + }) + + it("appends an optimistic branch copying the source metadata", async ({ + expect, + }) => { + const postCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches`, + () => ({ id: DOC_ID }), + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await api.createDocumentBranch.mutateAsync({ + docId: DOC_ID, + req: { branch: "draft", sourceBranchId: BRANCH_ID }, + }) + + // the branches entry has no active query, so the invalidation + // leaves the optimistic append in place + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + { + branchId: ANY_STRING, + branchName: "draft", + documentName: "Doc A", + icon: "icon-a", + protected: false, + default: false, + createdAt: ANY_DATE, + updatedAt: ANY_DATE, + }, + ]) + expect(postCalls).toHaveLength(1) + expect(postCalls[0]?.body).toEqual({ + branch: "draft", + sourceBranchId: BRANCH_ID, + }) + }) + + it("falls back to empty metadata when the source branch is not cached", async ({ + expect, + }) => { + mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => ({ + id: DOC_ID, + })) + seedQueryData(BRANCHES_KEY, []) + const api = makeDocumentAPI() + + await api.createDocumentBranch.mutateAsync({ + docId: DOC_ID, + req: { branch: "draft", sourceBranchId: BRANCH_ID }, + }) + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + expect.objectContaining({ + branchName: "draft", + documentName: "", + icon: "", + }), + ]) + }) + + it("rolls back the branches when the request fails", async ({ expect }) => { + mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => { + throw createError({ statusCode: 500 }) + }) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await expect( + api.createDocumentBranch.mutateAsync({ + docId: DOC_ID, + req: { branch: "draft", sourceBranchId: BRANCH_ID }, + }), + ).rejects.toThrow() + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + }) + + it("skips the rollback when the cache changed after the optimistic append", async ({ + expect, + }) => { + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches`, + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + const pending = api.createDocumentBranch.mutateAsync({ + docId: DOC_ID, + req: { branch: "draft", sourceBranchId: BRANCH_ID }, + }) + await post.reached + + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID_2, "divergent")]) + post.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID_2, "divergent"), + ]) + }) + }) + + describe("deleteDocumentBranch", () => { + it("bails out for a non-xid branch id", async ({ expect }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${SHORT_ID}`, + () => null, + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await api.deleteDocumentBranch.mutateAsync({ + docId: DOC_ID, + branchId: SHORT_ID, + }) + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + expect(deleteCalls).toHaveLength(0) + }) + + it("removes the branch optimistically", async ({ expect }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID_2}`, + () => null, + ) + seedQueryData(BRANCHES_KEY, [ + makeBranch(BRANCH_ID, "main"), + makeBranch(BRANCH_ID_2, "draft"), + ]) + const api = makeDocumentAPI() + + await api.deleteDocumentBranch.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID_2, + }) + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + expect(deleteCalls).toHaveLength(1) + }) + + it("leaves the branches unchanged for an unknown branch id", async ({ + expect, + }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID_2}`, + () => null, + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await api.deleteDocumentBranch.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID_2, + }) + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + expect(deleteCalls).toHaveLength(1) + }) + + it("rolls back the branches when the request fails", async ({ expect }) => { + mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + await expect( + api.deleteDocumentBranch.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + }), + ).rejects.toThrow() + + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID, "main"), + ]) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + const del = mockDeferredEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}`, + ) + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")]) + const api = makeDocumentAPI() + + const pending = api.deleteDocumentBranch.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + }) + await del.reached + + seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID_2, "divergent")]) + del.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(BRANCHES_KEY)).toEqual([ + makeBranch(BRANCH_ID_2, "divergent"), + ]) + }) + }) + + describe("mergeDocumentBranches", () => { + it("bails out for a non-xid branch id", async ({ expect }) => { + const mergeCalls = mockEndpoint("PUT", MERGE_URL, () => null) + const api = makeDocumentAPI() + + await api.mergeDocumentBranches.mutateAsync({ + docId: DOC_ID, + fromBranchId: SHORT_ID, + toBranchId: BRANCH_ID, + }) + + expect(mergeCalls).toHaveLength(0) + }) + + it("merges through the auth-realtime api", async ({ expect }) => { + const mergeCalls = mockEndpoint("PUT", MERGE_URL, () => null) + const api = makeDocumentAPI() + + await api.mergeDocumentBranches.mutateAsync({ + docId: DOC_ID, + fromBranchId: BRANCH_ID, + toBranchId: BRANCH_ID_2, + }) + + expect(mergeCalls).toHaveLength(1) + expect(mergeCalls[0]?.body).toEqual({ + fromBranchId: BRANCH_ID, + toBranchId: BRANCH_ID_2, + }) + }) + }) + + describe("useFetchDocumentMaintainersByDocId", () => { + it("returns no maintainers without a document id", async ({ expect }) => { + const maintainerCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/maintainers`, + () => [USER_ID], + ) + const api = makeDocumentAPI() + const maintainers = runInApp(() => + api.useFetchDocumentMaintainersByDocId(null), + ) + + const result = await maintainers.refresh() + + expect(result.data).toEqual([]) + expect(maintainerCalls).toHaveLength(0) + }) + + it("fetches the maintainers of the document", async ({ expect }) => { + const maintainerCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/maintainers`, + () => [USER_ID], + ) + const api = makeDocumentAPI() + const maintainers = runInApp(() => + api.useFetchDocumentMaintainersByDocId(DOC_ID), + ) + + const result = await maintainers.refresh() + + expect(result.data).toEqual([USER_ID]) + expect(maintainerCalls).toHaveLength(1) + }) + }) + + describe("useFetchDocumentBranchesByDocId", () => { + it("returns no branches without a document id", async ({ expect }) => { + const branchCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/branches`, + () => [makeBranch(BRANCH_ID, "main")], + ) + const api = makeDocumentAPI() + const branches = runInApp(() => api.useFetchDocumentBranchesByDocId(null)) + + const result = await branches.refresh() + + expect(result.data).toEqual([]) + expect(branchCalls).toHaveLength(0) + }) + + it("fetches the branches of the document", async ({ expect }) => { + const branchCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/branches`, + () => [makeBranch(BRANCH_ID, "main")], + ) + const api = makeDocumentAPI() + const branches = runInApp(() => + api.useFetchDocumentBranchesByDocId(DOC_ID), + ) + + const result = await branches.refresh() + + expect(result.data).toEqual([makeBranch(BRANCH_ID, "main")]) + expect(branchCalls).toHaveLength(1) + }) + }) + + describe("useFetchBranchReviewers", () => { + it("returns no reviewers without a branch id", async ({ expect }) => { + const reviewerCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => [], + ) + const api = makeDocumentAPI() + const reviewers = runInApp(() => + api.useFetchBranchReviewers(DOC_ID, null), + ) + + const result = await reviewers.refresh() + + expect(result.data).toEqual([]) + expect(reviewerCalls).toHaveLength(0) + }) + + it("fetches the reviewers of the branch", async ({ expect }) => { + const reviewerCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => [makeReviewer(USER_ID, true)], + ) + const api = makeDocumentAPI() + const reviewers = runInApp(() => + api.useFetchBranchReviewers(DOC_ID, BRANCH_ID), + ) + + const result = await reviewers.refresh() + + expect(result.data).toEqual([makeReviewer(USER_ID, true)]) + expect(reviewerCalls).toHaveLength(1) + }) + + it("maps a null response to an empty list", async ({ expect }) => { + const reviewerCalls = mockEndpoint( + "GET", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + const api = makeDocumentAPI() + const reviewers = runInApp(() => + api.useFetchBranchReviewers(DOC_ID, BRANCH_ID), + ) + + const result = await reviewers.refresh() + + expect(result.data).toEqual([]) + expect(reviewerCalls).toHaveLength(1) + }) + }) + + describe("updateBranchApproval", () => { + it("bails out for a non-xid branch id without a request", async ({ + expect, + }) => { + const putCalls = mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${SHORT_ID}/review-approve`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: SHORT_ID, + approved: true, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([]) + expect(putCalls).toHaveLength(0) + }) + + it("sends the request without an optimistic update when the session is unknown", async ({ + expect, + }) => { + const putCalls = mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/review-approve`, + () => null, + ) + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + approved: true, + }) + + // the mutation only guards the ids, so the request still goes out + // while the optimistic update is skipped + expect(readQueryData(REVIEWERS_KEY)).toEqual([]) + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual({ approved: true }) + }) + + it("toggles the approval of the current reviewer optimistically", async ({ + expect, + }) => { + const putCalls = mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/review-approve`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, false)]) + const api = makeDocumentAPI() + + await api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + approved: true, + }) + + // the reviewers entry has no active query, so the invalidation + // leaves the optimistic update in place + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, true), + ]) + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual({ approved: true }) + }) + + it("adds the current user as a reviewer when not present", async ({ + expect, + }) => { + mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/review-approve`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID_2, false)]) + const api = makeDocumentAPI() + + await api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + approved: true, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, false), + makeReviewer(USER_ID, true), + ]) + }) + + it("rolls back the reviewers when the request fails", async ({ + expect, + }) => { + mockEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/review-approve`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, false)]) + const api = makeDocumentAPI() + + await expect( + api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + approved: true, + }), + ).rejects.toThrow() + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, false), + ]) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + const put = mockDeferredEndpoint( + "PUT", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/review-approve`, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, false)]) + const api = makeDocumentAPI() + + const pending = api.updateBranchApproval.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + approved: true, + }) + await put.reached + + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID_2, true)]) + put.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, true), + ]) + }) + }) + + describe("inviteBranchReviewer", () => { + it.for([ + { name: "a non-xid document id", docId: SHORT_ID, userId: USER_ID_2 }, + { + name: "an optimistic-insert user id", + docId: DOC_ID, + userId: "optimistic-u1", + }, + ])("bails out for $name", async ({ docId, userId }, { expect }) => { + const postCalls = mockEndpoint( + "POST", + `/api/documents/${docId}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await api.inviteBranchReviewer.mutateAsync({ + docId, + branchId: BRANCH_ID, + userId, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([]) + expect(postCalls).toHaveLength(0) + }) + + it("sends the request without an optimistic update when the organization is unknown", async ({ + expect, + }) => { + const postCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await api.inviteBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([]) + expect(postCalls).toHaveLength(1) + expect(postCalls[0]?.body).toEqual({ userId: USER_ID_2 }) + }) + + it("adds a new reviewer optimistically", async ({ expect }) => { + const postCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await api.inviteBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, false), + ]) + expect(postCalls).toHaveLength(1) + expect(postCalls[0]?.body).toEqual({ userId: USER_ID_2 }) + }) + + it("resets the approvals of an already invited reviewer", async ({ + expect, + }) => { + mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, [ + { ...makeReviewer(USER_ID_2, true), previouslyApproved: true }, + ]) + const api = makeDocumentAPI() + + await api.inviteBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + + // currentlyApproved is cleared first, so previouslyApproved copies + // the already-cleared value and both end up false + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, false), + ]) + }) + + it("rolls back the reviewers when the request fails", async ({ + expect, + }) => { + mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + await expect( + api.inviteBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }), + ).rejects.toThrow() + + expect(readQueryData(REVIEWERS_KEY)).toEqual([]) + }) + + it("skips the rollback when the cache changed after the optimistic invite", async ({ + expect, + }) => { + const post = mockDeferredEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + ) + seedAuth() + seedQueryData(REVIEWERS_KEY, []) + const api = makeDocumentAPI() + + const pending = api.inviteBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + await post.reached + + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, true)]) + post.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, true), + ]) + }) + }) + + describe("removeBranchReviewer", () => { + it.for([ + { name: "a non-xid branch id", branchId: SHORT_ID, userId: USER_ID_2 }, + { + name: "an optimistic-insert user id", + branchId: BRANCH_ID, + userId: "optimistic-u1", + }, + ])("bails out for $name", async ({ branchId, userId }, { expect }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${branchId}/reviewers`, + () => null, + ) + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID_2, false)]) + const api = makeDocumentAPI() + + await api.removeBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId, + userId, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, false), + ]) + expect(deleteCalls).toHaveLength(0) + }) + + it("removes the reviewer optimistically", async ({ expect }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedQueryData(REVIEWERS_KEY, [ + makeReviewer(USER_ID, true), + makeReviewer(USER_ID_2, false), + ]) + const api = makeDocumentAPI() + + await api.removeBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, true), + ]) + expect(deleteCalls).toHaveLength(1) + expect(deleteCalls[0]?.query).toEqual({ userId: USER_ID_2 }) + }) + + it("leaves the reviewers unchanged for an unknown user", async ({ + expect, + }) => { + const deleteCalls = mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => null, + ) + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, true)]) + const api = makeDocumentAPI() + + await api.removeBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, true), + ]) + expect(deleteCalls).toHaveLength(1) + }) + + it("rolls back the reviewers when the request fails", async ({ + expect, + }) => { + mockEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID_2, false)]) + const api = makeDocumentAPI() + + await expect( + api.removeBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }), + ).rejects.toThrow() + + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID_2, false), + ]) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + const del = mockDeferredEndpoint( + "DELETE", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/reviewers`, + ) + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID_2, false)]) + const api = makeDocumentAPI() + + const pending = api.removeBranchReviewer.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + userId: USER_ID_2, + }) + await del.reached + + seedQueryData(REVIEWERS_KEY, [makeReviewer(USER_ID, true)]) + del.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(readQueryData(REVIEWERS_KEY)).toEqual([ + makeReviewer(USER_ID, true), + ]) + }) + }) +}) diff --git a/web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts b/web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts new file mode 100644 index 00000000..367cbd25 --- /dev/null +++ b/web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts @@ -0,0 +1,1344 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import type { + DocumentComment, + DocumentCommentReply, + DocumentCommentsResponse, +} from "~/utils/api/comment" +import { + ANY_DATE, + ANY_STRING, + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + readQueryData, + runInApp, + seedAuthOrganization, + seedAuthSession, + seedQueryData, +} from "./test-helpers" +import useDocumentCommentAPI from "./useDocumentCommentAPI" + +// isXid() only checks for a 20-character length, so these constants pass +// the mutations' id guards; NON_XID_ID fails them like a nanoid would +const DOC_ID = "docid123456789012345" +const BRANCH_ID = "branchid123456789012" +const COMMENT_ID = "commentid10000000001" +const COMMENT_ID_2 = "commentid20000000002" +const REPLY_ID = "replyid1000000000001" +const REPLY_ID_2 = "replyid2000000000002" +const USER_ID = "userid10000000000001" +const USER_ID_2 = "userid20000000000002" +const ORG_ID = "orgid100000000000001" +const NON_XID_ID = "nano1" + +const COMMENTS_KEY = ["documents", DOC_ID, "comments", BRANCH_ID] as const + +const COMMENTS_URL = `/api/documents/${DOC_ID}/comments` +const COMMENT_URL = `${COMMENTS_URL}/${COMMENT_ID}` +const RESOLVE_URL = `${COMMENT_URL}/resolve` +const REPLIES_URL = `${COMMENT_URL}/replies` +const REPLY_URL = `${REPLIES_URL}/${REPLY_ID}` + +function makeCommentAPI() { + return runInApp(() => useDocumentCommentAPI()) +} + +type DocumentCommentAPI = ReturnType + +function seedAuth() { + seedAuthSession(USER_ID) + seedAuthOrganization(ORG_ID) +} + +function seedComments(comments: DocumentCommentsResponse) { + seedQueryData(COMMENTS_KEY, comments) +} + +function readComments() { + return readQueryData(COMMENTS_KEY) as DocumentCommentsResponse | undefined +} + +// dates are ISO strings because clone() is JSON-based — cache contents +// round-trip through it, so string dates keep the comparisons simple +function makeComment( + id: string, + overrides: Partial = {}, +): DocumentComment { + return { + id, + organizationId: ORG_ID, + documentId: DOC_ID, + branchId: BRANCH_ID, + anchorBlockId: "block-1", + userId: USER_ID, + resolved: false, + resolvedBy: null, + content: { text: `comment ${id}` }, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: null, + diffDeletionContext: null, + ...overrides, + } +} + +function makeReply( + id: string, + overrides: Partial = {}, +): DocumentCommentReply { + return { + id, + organizationId: ORG_ID, + commentId: COMMENT_ID, + userId: USER_ID, + content: { text: `reply ${id}` }, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: null, + ...overrides, + } +} + +// creating the composable eagerly loads its queries once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic +describe("useDocumentCommentAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("useFetchDocumentCommentsByDocId", () => { + it.for([ + { name: "a document id", docId: null, branchId: BRANCH_ID }, + { name: "a branch id", docId: DOC_ID, branchId: null }, + ])( + "returns no comments without $name", + async ({ docId, branchId }, { expect }) => { + seedAuth() + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [ + makeComment(COMMENT_ID), + ]) + const api = makeCommentAPI() + const comments = runInApp(() => + api.useFetchDocumentCommentsByDocId(docId, branchId), + ) + + const result = await comments.refresh() + + expect(result.data).toEqual([]) + expect(getCalls).toHaveLength(0) + }, + ) + + it("fetches the comments of the branch", async ({ expect }) => { + seedAuth() + const serverComments = [makeComment(COMMENT_ID)] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + const api = makeCommentAPI() + const comments = runInApp(() => + api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID), + ) + + const result = await comments.refresh() + + expect(result.data).toEqual(serverComments) + expect(getCalls).toHaveLength(1) + expect(getCalls[0]?.query).toEqual({ branchId: BRANCH_ID }) + }) + }) + + // every mutation guards each of its ids with isXid() in every hook, so + // one short id makes the whole mutation a no-op: no optimistic write, + // no request, no invalidation + it.for([ + { + name: "createDocumentCommentByDocId", + method: "POST", + url: `/api/documents/${NON_XID_ID}/comments`, + invoke: (api: DocumentCommentAPI) => + api.createDocumentCommentByDocId.mutateAsync({ + docId: NON_XID_ID, + req: { + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }), + }, + { + name: "updateDocumentCommentByCommentId", + method: "PUT", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}`, + invoke: (api: DocumentCommentAPI) => + api.updateDocumentCommentByCommentId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { + content: { text: "edited" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }), + }, + { + name: "updateDocumentCommentResolveByCommentId", + method: "PUT", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/resolve`, + invoke: (api: DocumentCommentAPI) => + api.updateDocumentCommentResolveByCommentId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }), + }, + { + name: "deleteDocumentCommentByCommentId", + method: "DELETE", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}`, + invoke: (api: DocumentCommentAPI) => + api.deleteDocumentCommentByCommentId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }), + }, + { + name: "createDocumentCommentReplyByCommentId", + method: "POST", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies`, + invoke: (api: DocumentCommentAPI) => + api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }), + }, + { + name: "updateDocumentCommentReplyByReplyId", + method: "PUT", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies/${REPLY_ID}`, + invoke: (api: DocumentCommentAPI) => + api.updateDocumentCommentReplyByReplyId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + req: { content: { text: "edited" } }, + }), + }, + { + name: "deleteDocumentCommentReplyByReplyId", + method: "DELETE", + url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies/${REPLY_ID}`, + invoke: (api: DocumentCommentAPI) => + api.deleteDocumentCommentReplyByReplyId.mutateAsync({ + docId: NON_XID_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + }), + }, + ] as { + name: string + method: "GET" | "POST" | "PUT" | "DELETE" + url: string + invoke: (api: DocumentCommentAPI) => Promise + }[])( + "$name sends no request and keeps the cache when the document id is not an xid", + async ({ method, url, invoke }, { expect }) => { + seedAuth() + const existing = makeComment(COMMENT_ID) + seedComments([existing]) + const requestCalls = mockEndpoint(method, url, () => ({})) + const api = makeCommentAPI() + + const result = await invoke(api) + + expect(result).toBeUndefined() + expect(requestCalls).toHaveLength(0) + expect(readComments()).toEqual([existing]) + }, + ) + + describe("createDocumentCommentByDocId", () => { + it.for([ + { + name: "prepends an optimistic comment and refetches once the create succeeds", + makeReq: () => ({ + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }), + expectedDiffCtx: null, + }, + { + name: "keeps the given diff deletion context on the optimistic comment", + makeReq: () => ({ + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + diffDeletionContext: { + textAnchors: [{ nodeUid: "uid-1", fromOffset: 1, toOffset: 3 }], + }, + }), + expectedDiffCtx: { + textAnchors: [{ nodeUid: "uid-1", fromOffset: 1, toOffset: 3 }], + }, + }, + ])("$name", async ({ makeReq, expectedDiffCtx }, { expect }) => { + seedAuth() + const existing = makeComment(COMMENT_ID) + seedComments([existing]) + const serverComments = [ + makeComment(COMMENT_ID_2, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + const serverComment = makeComment(COMMENT_ID_2) + let commentsAtRequest: DocumentCommentsResponse | undefined + const postCalls = mockEndpoint("POST", COMMENTS_URL, () => { + commentsAtRequest = readComments() + + return serverComment + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + const req = makeReq() + + const result = await api.createDocumentCommentByDocId.mutateAsync({ + docId: DOC_ID, + req, + }) + + expect(result).toEqual(serverComment) + expect(postCalls).toHaveLength(1) + expect(postCalls[0]?.body).toEqual(req) + expect(commentsAtRequest).toEqual([ + expect.objectContaining({ + id: ANY_STRING, + organizationId: ORG_ID, + documentId: DOC_ID, + branchId: BRANCH_ID, + anchorBlockId: "block-9", + userId: USER_ID, + resolved: false, + resolvedBy: null, + content: { text: "new" }, + createdAt: ANY_DATE, + updatedAt: null, + diffDeletionContext: expectedDiffCtx, + }), + existing, + ]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("sends the create without an optimistic insert when session data is missing", async ({ + expect, + }) => { + const existing = makeComment(COMMENT_ID) + seedComments([existing]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + let commentsAtRequest: DocumentCommentsResponse | undefined + const postCalls = mockEndpoint("POST", COMMENTS_URL, () => { + commentsAtRequest = readComments() + + return makeComment(COMMENT_ID_2) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.createDocumentCommentByDocId.mutateAsync({ + docId: DOC_ID, + req: { + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }) + + expect(postCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([existing]) + // onMutate bailed out, so onSuccess has no mutation context and + // skips the invalidation refetch + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([existing]) + }) + + it("rolls back the optimistic comment when the create fails", async ({ + expect, + }) => { + seedAuth() + const existing = makeComment(COMMENT_ID) + seedComments([existing]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const postCalls = mockEndpoint("POST", COMMENTS_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.createDocumentCommentByDocId.mutateAsync({ + docId: DOC_ID, + req: { + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }), + ).rejects.toThrow() + + expect(postCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([existing]) + }) + + it("skips the rollback when the cache changed after the optimistic insert", async ({ + expect, + }) => { + let rejectPost: (err: unknown) => void = () => undefined + let postReached: () => void = () => undefined + const postReachedSignal = new Promise((resolve) => { + postReached = resolve + }) + + seedAuth() + seedComments([makeComment(COMMENT_ID)]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const postCalls = mockEndpoint("POST", COMMENTS_URL, () => { + postReached() + + return new Promise((_resolve, reject) => { + rejectPost = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.createDocumentCommentByDocId.mutateAsync({ + docId: DOC_ID, + req: { + content: { text: "new" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }) + await postReachedSignal + + // the optimistic insert landed; divergent data written afterwards + // must survive the failure + expect(readComments()).toHaveLength(2) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectPost(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(postCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("updateDocumentCommentByCommentId", () => { + it("updates the matching comment optimistically and refetches once the update succeeds", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID) + const other = makeComment(COMMENT_ID_2) + seedComments([target, other]) + const serverComments = [ + makeComment(COMMENT_ID, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const putCalls = mockEndpoint("PUT", COMMENT_URL, () => { + commentsAtRequest = readComments() + + return {} + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + const req = { + content: { text: "edited" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + } + + await api.updateDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req, + }) + + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual(req) + expect(commentsAtRequest).toEqual([ + { + ...target, + content: { text: "edited" }, + anchorBlockId: "block-9", + updatedAt: ANY_DATE, + }, + other, + ]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("rolls back the optimistic update when the update fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", COMMENT_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.updateDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { + content: { text: "edited" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }), + ).rejects.toThrow() + + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + seedAuth() + seedComments([makeComment(COMMENT_ID)]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", COMMENT_URL, () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.updateDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { + content: { text: "edited" }, + anchorBlockID: "block-9", + branchId: BRANCH_ID, + }, + }) + await putReachedSignal + + // the optimistic update landed; divergent data written afterwards + // must survive the failure + expect(readComments()?.[0]?.content).toEqual({ text: "edited" }) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("updateDocumentCommentResolveByCommentId", () => { + it("removes the resolved comment optimistically and refetches once the resolve succeeds", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID) + const other = makeComment(COMMENT_ID_2) + seedComments([target, other]) + const serverComments = [makeComment(COMMENT_ID_2)] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => { + commentsAtRequest = readComments() + + return {} + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.updateDocumentCommentResolveByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }) + + expect(putCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([other]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("rolls back the optimistic removal when the resolve fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.updateDocumentCommentResolveByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }), + ).rejects.toThrow() + + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + seedAuth() + seedComments([makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.updateDocumentCommentResolveByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }) + await putReachedSignal + + // the optimistic removal landed; divergent data written afterwards + // must survive the failure + expect(readComments()).toHaveLength(1) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("deleteDocumentCommentByCommentId", () => { + it.for([ + { + name: "replaces the deleted comment with its promoted oldest reply", + makeSeed: () => [ + makeComment(COMMENT_ID, { + replies: [ + makeReply(REPLY_ID_2, { createdAt: "2024-01-04T00:00:00.000Z" }), + makeReply(REPLY_ID, { + userId: USER_ID_2, + content: { text: "first reply" }, + createdAt: "2024-01-02T00:00:00.000Z", + updatedAt: "2024-01-03T00:00:00.000Z", + }), + ], + }), + makeComment(COMMENT_ID_2), + ], + makeExpectedAtRequest: () => [ + { + ...makeComment(COMMENT_ID), + userId: USER_ID_2, + content: { text: "first reply" }, + createdAt: "2024-01-02T00:00:00.000Z", + updatedAt: "2024-01-03T00:00:00.000Z", + replies: [ + makeReply(REPLY_ID_2, { createdAt: "2024-01-04T00:00:00.000Z" }), + ], + }, + makeComment(COMMENT_ID_2), + ], + }, + { + name: "removes the deleted comment when it has no replies", + makeSeed: () => [makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)], + makeExpectedAtRequest: () => [makeComment(COMMENT_ID_2)], + }, + ])("$name", async ({ makeSeed, makeExpectedAtRequest }, { expect }) => { + seedAuth() + seedComments(makeSeed()) + const serverComments = [ + makeComment(COMMENT_ID_2, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => { + commentsAtRequest = readComments() + + return {} + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.deleteDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }) + + expect(deleteCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual(makeExpectedAtRequest()) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("rolls back the optimistic removal when the delete fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.deleteDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }), + ).rejects.toThrow() + + expect(deleteCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + seedAuth() + seedComments([makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.deleteDocumentCommentByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + }) + await deleteReachedSignal + + // the optimistic removal landed; divergent data written afterwards + // must survive the failure + expect(readComments()).toHaveLength(1) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(deleteCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("createDocumentCommentReplyByCommentId", () => { + it("prepends an optimistic reply and refetches once the create succeeds", async ({ + expect, + }) => { + seedAuth() + const existingReply = makeReply(REPLY_ID) + const target = makeComment(COMMENT_ID, { replies: [existingReply] }) + const other = makeComment(COMMENT_ID_2) + seedComments([target, other]) + const serverComments = [ + makeComment(COMMENT_ID, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + const serverReply = makeReply(REPLY_ID_2) + let commentsAtRequest: DocumentCommentsResponse | undefined + const postCalls = mockEndpoint("POST", REPLIES_URL, () => { + commentsAtRequest = readComments() + + return serverReply + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const result = + await api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }) + + expect(result).toEqual(serverReply) + expect(postCalls).toHaveLength(1) + expect(postCalls[0]?.body).toEqual({ content: { text: "reply" } }) + expect(commentsAtRequest).toEqual([ + { + ...target, + replies: [ + expect.objectContaining({ + id: ANY_STRING, + organizationId: ORG_ID, + commentId: COMMENT_ID, + userId: USER_ID, + content: { text: "reply" }, + createdAt: ANY_DATE, + updatedAt: null, + }), + existingReply, + ], + }, + other, + ]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("sends the create without an optimistic insert when session data is missing", async ({ + expect, + }) => { + const target = makeComment(COMMENT_ID, { replies: [] }) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + let commentsAtRequest: DocumentCommentsResponse | undefined + const postCalls = mockEndpoint("POST", REPLIES_URL, () => { + commentsAtRequest = readComments() + + return makeReply(REPLY_ID) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }) + + expect(postCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([target]) + // onMutate bailed out, so onSuccess has no mutation context and + // skips the invalidation refetch + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the optimistic insert and the invalidation when the comment is not cached", async ({ + expect, + }) => { + seedAuth() + const other = makeComment(COMMENT_ID_2) + seedComments([other]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + let commentsAtRequest: DocumentCommentsResponse | undefined + const postCalls = mockEndpoint("POST", REPLIES_URL, () => { + commentsAtRequest = readComments() + + return makeReply(REPLY_ID) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }) + + expect(postCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([other]) + // onMutate returns before writing when the target comment is + // missing, so onSuccess has no mutation context and skips the + // invalidation refetch + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([other]) + }) + + it("rolls back the optimistic reply when the create fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const postCalls = mockEndpoint("POST", REPLIES_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }), + ).rejects.toThrow() + + expect(postCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic reply", async ({ + expect, + }) => { + let rejectPost: (err: unknown) => void = () => undefined + let postReached: () => void = () => undefined + const postReachedSignal = new Promise((resolve) => { + postReached = resolve + }) + + seedAuth() + seedComments([ + makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }), + ]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const postCalls = mockEndpoint("POST", REPLIES_URL, () => { + postReached() + + return new Promise((_resolve, reject) => { + rejectPost = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.createDocumentCommentReplyByCommentId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + req: { content: { text: "reply" } }, + }) + await postReachedSignal + + // the optimistic reply landed; divergent data written afterwards + // must survive the failure + expect(readComments()?.[0]?.replies).toHaveLength(2) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectPost(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(postCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("updateDocumentCommentReplyByReplyId", () => { + it("updates the matching reply optimistically and refetches once the update succeeds", async ({ + expect, + }) => { + seedAuth() + const targetReply = makeReply(REPLY_ID) + const otherReply = makeReply(REPLY_ID_2) + const target = makeComment(COMMENT_ID, { + replies: [targetReply, otherReply], + }) + const other = makeComment(COMMENT_ID_2) + seedComments([target, other]) + const serverComments = [ + makeComment(COMMENT_ID, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const putCalls = mockEndpoint("PUT", REPLY_URL, () => { + commentsAtRequest = readComments() + + return {} + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + const req = { content: { text: "edited" } } + + await api.updateDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + req, + }) + + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual(req) + expect(commentsAtRequest).toEqual([ + { + ...target, + replies: [ + { + ...targetReply, + content: { text: "edited" }, + updatedAt: ANY_DATE, + }, + otherReply, + ], + }, + other, + ]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("rolls back the optimistic update when the update fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", REPLY_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.updateDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + req: { content: { text: "edited" } }, + }), + ).rejects.toThrow() + + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + seedAuth() + seedComments([ + makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }), + ]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const putCalls = mockEndpoint("PUT", REPLY_URL, () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.updateDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + req: { content: { text: "edited" } }, + }) + await putReachedSignal + + // the optimistic update landed; divergent data written afterwards + // must survive the failure + expect(readComments()?.[0]?.replies?.[0]?.content).toEqual({ + text: "edited", + }) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(putCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("deleteDocumentCommentReplyByReplyId", () => { + it("removes the matching reply optimistically and refetches once the delete succeeds", async ({ + expect, + }) => { + seedAuth() + const otherReply = makeReply(REPLY_ID_2) + const target = makeComment(COMMENT_ID, { + replies: [makeReply(REPLY_ID), otherReply], + }) + const other = makeComment(COMMENT_ID_2) + seedComments([target, other]) + const serverComments = [ + makeComment(COMMENT_ID, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => { + commentsAtRequest = readComments() + + return {} + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.deleteDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + }) + + expect(deleteCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([ + { ...target, replies: [otherReply] }, + other, + ]) + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("leaves the replies unchanged when the reply is not cached", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }) + seedComments([target]) + const serverComments = [ + makeComment(COMMENT_ID, { content: { text: "server" } }), + ] + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments) + let commentsAtRequest: DocumentCommentsResponse | undefined + const deleteCalls = mockEndpoint( + "DELETE", + `${REPLIES_URL}/${REPLY_ID_2}`, + () => { + commentsAtRequest = readComments() + + return {} + }, + ) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await api.deleteDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID_2, + }) + + expect(deleteCalls).toHaveLength(1) + expect(commentsAtRequest).toEqual([target]) + // the target comment is cached, so onMutate still returns a + // context and onSuccess refetches even though nothing changed + expect(getCalls).toHaveLength(1) + expect(readComments()).toEqual(serverComments) + }) + + it("rolls back the optimistic removal when the delete fails", async ({ + expect, + }) => { + seedAuth() + const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }) + seedComments([target]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + await expect( + api.deleteDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + }), + ).rejects.toThrow() + + expect(deleteCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([target]) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + seedAuth() + seedComments([ + makeComment(COMMENT_ID, { + replies: [makeReply(REPLY_ID), makeReply(REPLY_ID_2)], + }), + ]) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeCommentAPI() + runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID)) + + const pending = api.deleteDocumentCommentReplyByReplyId.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + commentId: COMMENT_ID, + replyId: REPLY_ID, + }) + await deleteReachedSignal + + // the optimistic removal landed; divergent data written afterwards + // must survive the failure + expect(readComments()?.[0]?.replies).toHaveLength(1) + const divergent = makeComment(COMMENT_ID_2, { + content: { text: "divergent" }, + }) + seedComments([divergent]) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(deleteCalls).toHaveLength(1) + expect(getCalls).toHaveLength(0) + expect(readComments()).toEqual([divergent]) + }) + }) + + describe("patchOptimisticCommentEntry", () => { + it("does nothing when no comments are cached", ({ expect }) => { + seedAuth() + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const api = makeCommentAPI() + + api.patchOptimisticCommentEntry( + DOC_ID, + BRANCH_ID, + makeComment(COMMENT_ID), + ) + + expect(readComments()).toBeUndefined() + expect(getCalls).toHaveLength(0) + }) + + it.for([ + { + name: "leaves the cached comments untouched when no optimistic entry exists", + makeSeed: () => [makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)], + makeExpected: (server: DocumentComment) => { + void server + + return [makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)] + }, + }, + { + name: "replaces the optimistic entry with the server comment", + makeSeed: () => [makeComment(NON_XID_ID), makeComment(COMMENT_ID_2)], + makeExpected: (server: DocumentComment) => [ + server, + makeComment(COMMENT_ID_2), + ], + }, + ])("$name", ({ makeSeed, makeExpected }, { expect }) => { + seedAuth() + seedComments(makeSeed()) + const getCalls = mockEndpoint("GET", COMMENTS_URL, () => []) + const api = makeCommentAPI() + const serverComment = makeComment(COMMENT_ID, { + content: { text: "server" }, + }) + + api.patchOptimisticCommentEntry(DOC_ID, BRANCH_ID, serverComment) + + expect(readComments()).toEqual(makeExpected(serverComment)) + expect(getCalls).toHaveLength(0) + }) + }) +}) diff --git a/web/app/composables/api/useDocumentFileAPI.nuxt.test.ts b/web/app/composables/api/useDocumentFileAPI.nuxt.test.ts new file mode 100644 index 00000000..97ac6051 --- /dev/null +++ b/web/app/composables/api/useDocumentFileAPI.nuxt.test.ts @@ -0,0 +1,123 @@ +import { registerEndpoint } from "@nuxt/test-utils/runtime" +import { getQuery, setResponseHeader, type H3Event } from "h3" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + runInApp, + trackEndpointDisposal, +} from "./test-helpers" +import useDocumentFileAPI, { buildDocumentFileSrc } from "./useDocumentFileAPI" + +function makeDocumentFileAPI() { + return runInApp(() => useDocumentFileAPI()) +} + +interface RecordedUpload { + query: Record + fieldNames: string[] +} + +// registers an upload handler on the test-time h3 app that the real api +// clients route through, recording the query parameters and multipart +// field names of each call for accounting. The test transport hands the +// fetch body to the handler unserialized (node-mock-http assigns it +// straight to req.body), so the field names are read off the original +// FormData rather than parsed from a wire format. The handler answers +// with the given location header, or with no header at all when location +// is omitted. +function mockUploadEndpoint(url: string, location?: string): RecordedUpload[] { + const calls: RecordedUpload[] = [] + + const dispose = registerEndpoint(url, { + method: "POST", + handler: (event: H3Event) => { + const body = (event.node.req as { body?: unknown }).body + + calls.push({ + query: getQuery(event), + fieldNames: body instanceof FormData ? [...body.keys()] : [], + }) + + if (location !== undefined) { + setResponseHeader(event, "location", location) + } + + return null + }, + }) + + trackEndpointDisposal(dispose) + + return calls +} + +// the tests share the app-wide query cache and the test-time endpoint +// registry, so they cannot interleave +describe("useDocumentFileAPI", { concurrent: false }, () => { + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("buildDocumentFileSrc", () => { + it("builds the file url from the document and block ids", ({ expect }) => { + const src = runInApp(() => buildDocumentFileSrc("doc-1", "block-1")) + + // the test runtime config leaves coreAPIBaseHttpURL empty, so + // the url is origin-relative + expect(src).toBe("/api/documents/doc-1/files/block-1") + }) + }) + + describe("uploadDocumentFile", () => { + it("uploads the file and returns the stored location", async ({ + expect, + }) => { + const uploadCalls = mockUploadEndpoint( + "/api/documents/doc-1/files", + "/files/document-file-1", + ) + const api = makeDocumentFileAPI() + const file = new File(["x"], "x.png", { type: "image/png" }) + + const location = await api.uploadDocumentFile.mutateAsync({ + documentId: "doc-1", + id: "block 1/a", + loc: DocumentFileLocation.Document, + file, + }) + + expect(location).toBe("/files/document-file-1") + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.query).toEqual({ + id: "block 1/a", + location: "document", + }) + expect(uploadCalls[0]?.fieldNames).toEqual(["file"]) + }) + + it("rejects when the response has no location header", async ({ + expect, + }) => { + const uploadCalls = mockUploadEndpoint("/api/documents/doc-1/files") + const api = makeDocumentFileAPI() + const file = new File(["x"], "x.png", { type: "image/png" }) + + await expect( + api.uploadDocumentFile.mutateAsync({ + documentId: "doc-1", + id: "block-1", + loc: DocumentFileLocation.Comment, + file, + }), + ).rejects.toThrow("missing location header") + + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.query).toEqual({ + id: "block-1", + location: "comment", + }) + expect(uploadCalls[0]?.fieldNames).toEqual(["file"]) + }) + }) +}) diff --git a/web/app/composables/api/useDocumentHookAPI.nuxt.test.ts b/web/app/composables/api/useDocumentHookAPI.nuxt.test.ts new file mode 100644 index 00000000..54b2b949 --- /dev/null +++ b/web/app/composables/api/useDocumentHookAPI.nuxt.test.ts @@ -0,0 +1,699 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import { + ANY_DATE, + ANY_STRING, + clearQueryCache, + disposeMockEndpoints, + makeXid, + mockDeferredEndpoint, + mockEndpoint, + readQueryData, + runInApp, + seedAuthOrganization, + seedQueryData, +} from "./test-helpers" +import useDocumentHookAPI from "./useDocumentHookAPI" + +const DOC_ID = makeXid("doc1") +const BRANCH_ID = makeXid("bra1") +const HOOK_ID = makeXid("hoo1") +const OTHER_HOOK_ID = makeXid("hoo2") +const MISSING_HOOK_ID = makeXid("hoo3") +const ORG_ID = makeXid("org1") +const NON_XID = "nano-id" +const HOOKS_KEY = ["documents", "hooks", DOC_ID, BRANCH_ID] as const +const LIST_URL = `/api/documents/${DOC_ID}/hooks` + +const CREATE_REQ: DocumentHookCreateRequest = { + type: DocumentHookType.GitHubTracking, + branchId: BRANCH_ID, + blockId: "block1", + settings: { repository: "acme/docs", branch: "main", paths: ["a.md"] }, +} + +const UPDATE_REQ: DocumentHookUpdateRequest = { + settings: { repository: "acme/docs", branch: "dev", paths: ["b.md"] }, +} + +function makeDocumentHookAPI() { + return runInApp(() => useDocumentHookAPI()) +} + +// cache seeds pass through the composable's JSON-based clone, so the +// fixture uses string dates — exactly what a cache round-trip produces. +// The state is non-default so a reset is observable. +function makeHook(id: string): DocumentHook { + return { + id, + type: DocumentHookType.GitHubTracking, + documentId: DOC_ID, + organizationId: ORG_ID, + branchId: BRANCH_ID, + blockId: "block1", + settings: { repository: "acme/docs", branch: "main", paths: ["a.md"] }, + state: { pathsChecksums: { "a.md": "sum-a" }, status: "missing_branch" }, + score: "100", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + } +} + +function seedOrganization() { + seedAuthOrganization(ORG_ID) +} + +function seedHooks(hooks: DocumentHook[]) { + seedQueryData(HOOKS_KEY, hooks) +} + +function getHooks() { + return readQueryData(HOOKS_KEY) +} + +// creating the composable eagerly loads its queries once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic +describe("useDocumentHookAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("useFetchDocumentHooksByDocID", () => { + it.for([ + { + name: "resolves no hooks without a document id", + docId: null, + branchId: BRANCH_ID, + }, + { + name: "resolves no hooks without a branch id", + docId: DOC_ID, + branchId: null, + }, + ])("$name", async ({ docId, branchId }, { expect }) => { + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(docId, branchId), + ) + + const result = await hooks.refresh() + + expect(result.data).toEqual([]) + expect(listCalls).toHaveLength(0) + }) + + it("fetches the hooks of the branch", async ({ expect }) => { + const listCalls = mockEndpoint("GET", LIST_URL, () => [makeHook(HOOK_ID)]) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(DOC_ID, BRANCH_ID), + ) + + const result = await hooks.refresh() + + expect(result.data).toEqual([makeHook(HOOK_ID)]) + expect(listCalls).toHaveLength(1) + expect(listCalls[0]?.query).toEqual({ branchId: BRANCH_ID }) + }) + }) + + describe("createDocumentHookByDocID", () => { + it.for([ + { + name: "skips the create for a non-xid document id", + docId: NON_XID, + branchId: BRANCH_ID, + }, + { + name: "skips the create for a non-xid branch id", + docId: DOC_ID, + branchId: NON_XID, + }, + ])("$name", async ({ docId, branchId }, { expect }) => { + seedOrganization() + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const createCalls = mockEndpoint("POST", LIST_URL, () => ({})) + const api = makeDocumentHookAPI() + + await api.createDocumentHookByDocID.mutateAsync({ + docId, + req: { ...CREATE_REQ, branchId }, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(createCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("sends the create but skips the optimistic insert when no organization is loaded", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const createCalls = mockEndpoint("POST", LIST_URL, () => + makeHook(OTHER_HOOK_ID), + ) + const api = makeDocumentHookAPI() + + await api.createDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + req: CREATE_REQ, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.body).toEqual(CREATE_REQ) + expect(listCalls).toHaveLength(0) + }) + + it("optimistically prepends the new hook and refetches the list on success", async ({ + expect, + }) => { + const createdHook = { + ...makeHook(OTHER_HOOK_ID), + settings: CREATE_REQ.settings, + } + const serverHooks = [createdHook, makeHook(HOOK_ID)] + + seedOrganization() + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => serverHooks) + const create = mockDeferredEndpoint("POST", LIST_URL) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(DOC_ID, BRANCH_ID), + ) + + const pending = api.createDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + req: CREATE_REQ, + }) + await create.reached + + // the optimistic insert stamps a nanoid id and fresh dates + expect(getHooks()).toEqual([ + { + id: ANY_STRING, + type: DocumentHookType.GitHubTracking, + documentId: DOC_ID, + branchId: BRANCH_ID, + organizationId: ORG_ID, + blockId: "block1", + settings: CREATE_REQ.settings, + state: { pathsChecksums: {}, status: "active" }, + score: "100", + createdAt: ANY_DATE, + updatedAt: ANY_DATE, + }, + makeHook(HOOK_ID), + ]) + create.resolve(createdHook) + + await pending + expect(create.calls).toHaveLength(1) + expect(create.calls[0]?.body).toEqual(CREATE_REQ) + // the success invalidation refetches the active hooks query + expect(listCalls).toHaveLength(1) + expect(hooks.data.value).toEqual(serverHooks) + }) + + it("rolls back the optimistic insert when the request fails", async ({ + expect, + }) => { + seedOrganization() + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const createCalls = mockEndpoint("POST", LIST_URL, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeDocumentHookAPI() + + await expect( + api.createDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + req: CREATE_REQ, + }), + ).rejects.toThrow() + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(createCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic insert", async ({ + expect, + }) => { + seedOrganization() + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const create = mockDeferredEndpoint("POST", LIST_URL) + const api = makeDocumentHookAPI() + + const pending = api.createDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + req: CREATE_REQ, + }) + await create.reached + + // divergent data written after the optimistic insert must survive + // the failure + seedHooks([makeHook(OTHER_HOOK_ID)]) + create.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(getHooks()).toEqual([makeHook(OTHER_HOOK_ID)]) + expect(create.calls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("updateDocumentHookByDocID", () => { + it.for([ + { + name: "skips the update for a non-xid document id", + docId: NON_XID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }, + { + name: "skips the update for a non-xid branch id", + docId: DOC_ID, + branchId: NON_XID, + hookId: HOOK_ID, + }, + { + name: "skips the update for a non-xid hook id", + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: NON_XID, + }, + ])("$name", async ({ docId, branchId, hookId }, { expect }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const updateCalls = mockEndpoint( + "PUT", + `${LIST_URL}/${HOOK_ID}`, + () => ({}), + ) + const api = makeDocumentHookAPI() + + await api.updateDocumentHookByDocID.mutateAsync({ + docId, + branchId, + hookId, + req: UPDATE_REQ, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(updateCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("optimistically replaces the hook settings and refetches the list on success", async ({ + expect, + }) => { + const updatedHook = { + ...makeHook(HOOK_ID), + settings: UPDATE_REQ.settings, + updatedAt: "2024-02-01T00:00:00.000Z", + } + const serverHooks = [updatedHook, makeHook(OTHER_HOOK_ID)] + + seedHooks([makeHook(HOOK_ID), makeHook(OTHER_HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => serverHooks) + const update = mockDeferredEndpoint("PUT", `${LIST_URL}/${HOOK_ID}`) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(DOC_ID, BRANCH_ID), + ) + + const pending = api.updateDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + req: UPDATE_REQ, + }) + await update.reached + + // only the matching hook gets the new settings and a bumped + // updatedAt; the other hook stays untouched + expect(getHooks()).toEqual([ + { + ...makeHook(HOOK_ID), + settings: UPDATE_REQ.settings, + updatedAt: ANY_DATE, + }, + makeHook(OTHER_HOOK_ID), + ]) + update.resolve(updatedHook) + + await pending + expect(update.calls).toHaveLength(1) + expect(update.calls[0]?.body).toEqual(UPDATE_REQ) + // the success invalidation refetches the active hooks query + expect(listCalls).toHaveLength(1) + expect(hooks.data.value).toEqual(serverHooks) + }) + + it("rolls back the optimistic update when the request fails", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const updateCalls = mockEndpoint("PUT", `${LIST_URL}/${HOOK_ID}`, () => { + throw createError({ statusCode: 500 }) + }) + const api = makeDocumentHookAPI() + + await expect( + api.updateDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + req: UPDATE_REQ, + }), + ).rejects.toThrow() + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(updateCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const update = mockDeferredEndpoint("PUT", `${LIST_URL}/${HOOK_ID}`) + const api = makeDocumentHookAPI() + + const pending = api.updateDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + req: UPDATE_REQ, + }) + await update.reached + + // divergent data written after the optimistic update must survive + // the failure + seedHooks([makeHook(OTHER_HOOK_ID)]) + update.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(getHooks()).toEqual([makeHook(OTHER_HOOK_ID)]) + expect(update.calls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("deleteDocumentHookByDocID", () => { + it.for([ + { + name: "skips the delete for a non-xid document id", + docId: NON_XID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }, + { + name: "skips the delete for a non-xid branch id", + docId: DOC_ID, + branchId: NON_XID, + hookId: HOOK_ID, + }, + { + name: "skips the delete for a non-xid hook id", + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: NON_XID, + }, + ])("$name", async ({ docId, branchId, hookId }, { expect }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const deleteCalls = mockEndpoint( + "DELETE", + `${LIST_URL}/${HOOK_ID}`, + () => ({}), + ) + const api = makeDocumentHookAPI() + + await api.deleteDocumentHookByDocID.mutateAsync({ + docId, + branchId, + hookId, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(deleteCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("optimistically removes the hook and refetches the list on success", async ({ + expect, + }) => { + const serverHooks = [makeHook(OTHER_HOOK_ID)] + + seedHooks([makeHook(HOOK_ID), makeHook(OTHER_HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => serverHooks) + const remove = mockDeferredEndpoint("DELETE", `${LIST_URL}/${HOOK_ID}`) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(DOC_ID, BRANCH_ID), + ) + + const pending = api.deleteDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }) + await remove.reached + + expect(getHooks()).toEqual([makeHook(OTHER_HOOK_ID)]) + remove.resolve({}) + + await pending + expect(remove.calls).toHaveLength(1) + // the success invalidation refetches the active hooks query + expect(listCalls).toHaveLength(1) + expect(hooks.data.value).toEqual(serverHooks) + }) + + it("leaves the list unchanged when the hook id is not in the cache", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const deleteCalls = mockEndpoint( + "DELETE", + `${LIST_URL}/${MISSING_HOOK_ID}`, + () => ({}), + ) + const api = makeDocumentHookAPI() + + await api.deleteDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: MISSING_HOOK_ID, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(deleteCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("rolls back the optimistic removal when the request fails", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const deleteCalls = mockEndpoint( + "DELETE", + `${LIST_URL}/${HOOK_ID}`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + const api = makeDocumentHookAPI() + + await expect( + api.deleteDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }), + ).rejects.toThrow() + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(deleteCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic removal", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const remove = mockDeferredEndpoint("DELETE", `${LIST_URL}/${HOOK_ID}`) + const api = makeDocumentHookAPI() + + const pending = api.deleteDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }) + await remove.reached + + // divergent data written after the optimistic removal must survive + // the failure + seedHooks([makeHook(OTHER_HOOK_ID)]) + remove.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(getHooks()).toEqual([makeHook(OTHER_HOOK_ID)]) + expect(remove.calls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) + + describe("resetDocumentHookByDocID", () => { + it.for([ + { + name: "skips the reset for a non-xid document id", + docId: NON_XID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }, + { + name: "skips the reset for a non-xid branch id", + docId: DOC_ID, + branchId: NON_XID, + hookId: HOOK_ID, + }, + { + name: "skips the reset for a non-xid hook id", + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: NON_XID, + }, + ])("$name", async ({ docId, branchId, hookId }, { expect }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const resetCalls = mockEndpoint( + "PUT", + `${LIST_URL}/${HOOK_ID}/reset`, + () => ({}), + ) + const api = makeDocumentHookAPI() + + await api.resetDocumentHookByDocID.mutateAsync({ + docId, + branchId, + hookId, + }) + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(resetCalls).toHaveLength(0) + expect(listCalls).toHaveLength(0) + }) + + it("optimistically resets the hook state and refetches the list on success", async ({ + expect, + }) => { + const resetHook = { + ...makeHook(HOOK_ID), + state: { pathsChecksums: {}, status: "active" }, + } + const serverHooks = [resetHook, makeHook(OTHER_HOOK_ID)] + + seedHooks([makeHook(HOOK_ID), makeHook(OTHER_HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => serverHooks) + const reset = mockDeferredEndpoint("PUT", `${LIST_URL}/${HOOK_ID}/reset`) + const api = makeDocumentHookAPI() + const hooks = runInApp(() => + api.useFetchDocumentHooksByDocID(DOC_ID, BRANCH_ID), + ) + + const pending = api.resetDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }) + await reset.reached + + // only the matching hook gets the default state for its type and a + // bumped updatedAt; the other hook stays untouched + expect(getHooks()).toEqual([ + { + ...makeHook(HOOK_ID), + state: { pathsChecksums: {}, status: "active" }, + updatedAt: ANY_DATE, + }, + makeHook(OTHER_HOOK_ID), + ]) + reset.resolve(resetHook) + + await pending + expect(reset.calls).toHaveLength(1) + // the success invalidation refetches the active hooks query + expect(listCalls).toHaveLength(1) + expect(hooks.data.value).toEqual(serverHooks) + }) + + it("rolls back the optimistic reset when the request fails", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const resetCalls = mockEndpoint( + "PUT", + `${LIST_URL}/${HOOK_ID}/reset`, + () => { + throw createError({ statusCode: 500 }) + }, + ) + const api = makeDocumentHookAPI() + + await expect( + api.resetDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }), + ).rejects.toThrow() + + expect(getHooks()).toEqual([makeHook(HOOK_ID)]) + expect(resetCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic reset", async ({ + expect, + }) => { + seedHooks([makeHook(HOOK_ID)]) + const listCalls = mockEndpoint("GET", LIST_URL, () => []) + const reset = mockDeferredEndpoint("PUT", `${LIST_URL}/${HOOK_ID}/reset`) + const api = makeDocumentHookAPI() + + const pending = api.resetDocumentHookByDocID.mutateAsync({ + docId: DOC_ID, + branchId: BRANCH_ID, + hookId: HOOK_ID, + }) + await reset.reached + + // divergent data written after the optimistic reset must survive + // the failure + seedHooks([makeHook(OTHER_HOOK_ID)]) + reset.reject(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(getHooks()).toEqual([makeHook(OTHER_HOOK_ID)]) + expect(reset.calls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + }) + }) +}) diff --git a/web/app/composables/api/useGitHubAPI.nuxt.test.ts b/web/app/composables/api/useGitHubAPI.nuxt.test.ts new file mode 100644 index 00000000..8fa3b1bf --- /dev/null +++ b/web/app/composables/api/useGitHubAPI.nuxt.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, + seedQueryData, +} from "./test-helpers" +import useGitHubAPI from "./useGitHubAPI" + +const CONNECTED_KEY = ["github", "connected"] as const + +function makeGitHubAPI() { + return runInApp(() => useGitHubAPI()) +} + +// creating the composable eagerly loads its queries once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic +describe("useGitHubAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("fetchGitHubConnectionStatus", () => { + it("fetches the connection status", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const api = makeGitHubAPI() + + const result = await api.fetchGitHubConnectionStatus.refresh() + + expect(result.data).toEqual({ connected: true, configured: true }) + expect(statusCalls).toHaveLength(1) + }) + }) + + describe("gitHubConfigured", () => { + it("reports configured while the status is unknown", ({ expect }) => { + const api = makeGitHubAPI() + + expect(api.gitHubConfigured.value).toBe(true) + }) + + it("reports unconfigured once the status says so", async ({ expect }) => { + mockEndpoint("GET", "/api/github", () => ({ + connected: false, + configured: false, + })) + const api = makeGitHubAPI() + + await api.fetchGitHubConnectionStatus.refresh() + + expect(api.gitHubConfigured.value).toBe(false) + }) + }) + + describe("fetchGitHubInstallURL", () => { + it("fetches the install url", async ({ expect }) => { + const installCalls = mockEndpoint("GET", "/api/github/install", () => ({ + url: "https://github.test/i", + })) + const api = makeGitHubAPI() + + const result = await api.fetchGitHubInstallURL() + + expect(result).toEqual({ url: "https://github.test/i" }) + expect(installCalls).toHaveLength(1) + }) + }) + + describe("connectGitHub", () => { + it("connects with the callback query parameters", async ({ expect }) => { + const connectCalls = mockEndpoint( + "GET", + "/api/github/connect", + () => ({}), + ) + const api = makeGitHubAPI() + + await api.connectGitHub.mutateAsync(new URLSearchParams({ code: "c1" })) + + expect(connectCalls).toHaveLength(1) + expect(connectCalls[0]?.query).toEqual({ code: "c1" }) + }) + + it("connects without query parameters", async ({ expect }) => { + const connectCalls = mockEndpoint( + "GET", + "/api/github/connect", + () => ({}), + ) + const api = makeGitHubAPI() + + await api.connectGitHub.mutateAsync(new URLSearchParams()) + + expect(connectCalls).toHaveLength(1) + expect(connectCalls[0]?.query).toEqual({}) + }) + }) + + describe("disconnectGitHub", () => { + it("disconnects and refreshes the connection status", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const deleteCalls = mockEndpoint("DELETE", "/api/github", () => ({})) + const api = makeGitHubAPI() + await api.fetchGitHubConnectionStatus.refresh() + + await api.disconnectGitHub.mutateAsync() + + expect(deleteCalls).toHaveLength(1) + // the success invalidation refetches the active status query + expect(statusCalls).toHaveLength(2) + expect(api.fetchGitHubConnectionStatus.data.value).toEqual({ + connected: true, + configured: true, + }) + }) + + it("rolls back the optimistic disconnect when the request fails", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("DELETE", "/api/github", () => { + throw createError({ statusCode: 500 }) + }) + const api = makeGitHubAPI() + await api.fetchGitHubConnectionStatus.refresh() + + await expect(api.disconnectGitHub.mutateAsync()).rejects.toThrow() + + expect(statusCalls).toHaveLength(1) + expect(api.fetchGitHubConnectionStatus.data.value).toEqual({ + connected: true, + configured: true, + }) + }) + + it("skips the rollback when the cache changed after the optimistic disconnect", async ({ + expect, + }) => { + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("DELETE", "/api/github", () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeGitHubAPI() + await api.fetchGitHubConnectionStatus.refresh() + + const pending = api.disconnectGitHub.mutateAsync() + await deleteReachedSignal + + // the optimistic update landed; divergent data written afterwards + // must survive the failure + expect(api.fetchGitHubConnectionStatus.data.value).toEqual({ + connected: false, + configured: true, + }) + seedQueryData(CONNECTED_KEY, { + connected: true, + configured: false, + }) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(api.fetchGitHubConnectionStatus.data.value).toEqual({ + connected: true, + configured: false, + }) + }) + }) + + describe("fetchGitHubRepositories", () => { + it("returns no repositories when github is not connected", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: false, + configured: true, + })) + const repoCalls = mockEndpoint( + "GET", + "/api/github/repositories", + () => [], + ) + const api = makeGitHubAPI() + + const result = await api.fetchGitHubRepositories.refresh() + + expect(result.data).toEqual([]) + expect(repoCalls).toHaveLength(0) + expect(statusCalls).toHaveLength(1) + }) + + it("fetches the repositories when github is connected", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const repoCalls = mockEndpoint("GET", "/api/github/repositories", () => [ + { name: "acme" }, + ]) + const api = makeGitHubAPI() + + const result = await api.fetchGitHubRepositories.refresh() + + expect(result.data).toEqual([{ name: "acme" }]) + expect(repoCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + }) + + describe("useFetchGitHubBranchesByRepositoryName", () => { + it("returns no branches when github is not connected", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: false, + configured: true, + })) + const branchCalls = mockEndpoint( + "GET", + "/api/github/repositories/acme/branches", + () => [], + ) + const api = makeGitHubAPI() + const branches = runInApp(() => + api.useFetchGitHubBranchesByRepositoryName("acme"), + ) + + const result = await branches.refresh() + + expect(result.data).toEqual([]) + expect(branchCalls).toHaveLength(0) + expect(statusCalls).toHaveLength(1) + }) + + it("returns no branches without a repository name", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const api = makeGitHubAPI() + const branches = runInApp(() => + api.useFetchGitHubBranchesByRepositoryName(null), + ) + + const result = await branches.refresh() + + expect(result.data).toEqual([]) + expect(statusCalls).toHaveLength(1) + }) + + it("fetches the branches of the repository", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const branchCalls = mockEndpoint( + "GET", + "/api/github/repositories/acme/branches", + () => ["main", "dev"], + ) + const api = makeGitHubAPI() + const branches = runInApp(() => + api.useFetchGitHubBranchesByRepositoryName("acme"), + ) + + const result = await branches.refresh() + + expect(result.data).toEqual(["main", "dev"]) + expect(branchCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + }) + + describe("useFetchGitHubFileTreeByRepositoryNameAndBranch", () => { + it("returns no tree items when github is not connected", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: false, + configured: true, + })) + const treeCalls = mockEndpoint( + "GET", + "/api/github/repositories/acme/tree", + () => [], + ) + const api = makeGitHubAPI() + const tree = runInApp(() => + api.useFetchGitHubFileTreeByRepositoryNameAndBranch("acme", "main"), + ) + + const result = await tree.refresh() + + expect(result.data).toEqual([]) + expect(treeCalls).toHaveLength(0) + expect(statusCalls).toHaveLength(1) + }) + + it("returns no tree items without a repository name", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const api = makeGitHubAPI() + const tree = runInApp(() => + api.useFetchGitHubFileTreeByRepositoryNameAndBranch(null, "main"), + ) + + const result = await tree.refresh() + + expect(result.data).toEqual([]) + expect(statusCalls).toHaveLength(1) + }) + + it("fetches the file tree of the branch", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const treeCalls = mockEndpoint( + "GET", + "/api/github/repositories/acme/tree", + () => [{ path: "README.md" }], + ) + const api = makeGitHubAPI() + const tree = runInApp(() => + api.useFetchGitHubFileTreeByRepositoryNameAndBranch("acme", "main"), + ) + + const result = await tree.refresh() + + expect(result.data).toEqual([{ path: "README.md" }]) + expect(treeCalls).toHaveLength(1) + expect(treeCalls[0]?.query).toEqual({ branch: "main" }) + expect(statusCalls).toHaveLength(1) + }) + + it("fetches the default branch tree when no branch is given", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/github", () => ({ + connected: true, + configured: true, + })) + const treeCalls = mockEndpoint( + "GET", + "/api/github/repositories/acme/tree", + () => [], + ) + const api = makeGitHubAPI() + const tree = runInApp(() => + api.useFetchGitHubFileTreeByRepositoryNameAndBranch("acme", null), + ) + + await tree.refresh() + + expect(treeCalls).toHaveLength(1) + expect(treeCalls[0]?.query).toEqual({}) + expect(statusCalls).toHaveLength(1) + }) + }) +}) diff --git a/web/app/composables/api/useNotificationAPI.nuxt.test.ts b/web/app/composables/api/useNotificationAPI.nuxt.test.ts new file mode 100644 index 00000000..5aab0774 --- /dev/null +++ b/web/app/composables/api/useNotificationAPI.nuxt.test.ts @@ -0,0 +1,361 @@ +import type { EntryKey } from "@pinia/colada" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + readQueryData, + runInApp, + seedQueryData, +} from "./test-helpers" +import useNotificationAPI from "./useNotificationAPI" + +const LIST_KEY_A = ["notifications", "list", 10, 1] as const +const LIST_KEY_B = ["notifications", "list", 5, 2] as const + +function makeNotificationAPI() { + return runInApp(() => useNotificationAPI()) +} + +// cache seeds pass through the composable's JSON-based clone, so the +// fixture uses a string date — exactly what a cache round-trip produces +function makeNotification(id: string, read: boolean) { + return { + id, + userId: "u1", + organizationId: "org1", + code: NotificationCode.DocumentNewComment, + metadata: { + userId: "u2", + documentId: "d1", + branchId: "b1", + commentId: "c1", + anchorBlockId: null, + }, + read, + createdAt: "2024-01-01T00:00:00.000Z", + } +} + +function makePage( + notifications: NotificationsResponse["notifications"], +): NotificationsResponse { + return { notifications, pageCount: 1 } +} + +function seedPage(key: EntryKey, page: NotificationsResponse) { + seedQueryData(key, page) +} + +function getPage(key: EntryKey) { + return readQueryData(key) as NotificationsResponse | undefined +} + +// creating a factory query eagerly loads it once; refresh() joins that +// in-flight load (or reuses its fresh result) instead of forcing a second +// request, which keeps the call accounting deterministic +describe("useNotificationAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("useFetchManyNotifications", () => { + it("fetches the requested notification page", async ({ expect }) => { + const page = makePage([makeNotification("n1", false)]) + const listCalls = mockEndpoint("GET", "/api/notifications", () => page) + const api = makeNotificationAPI() + const list = runInApp(() => + api.useFetchManyNotifications({ limit: 10, page: 2 }), + ) + + const result = await list.refresh() + + expect(result.data).toEqual(page) + expect(listCalls).toHaveLength(1) + expect(listCalls[0]?.query).toEqual({ limit: "10", page: "2" }) + }) + }) + + describe("useFetchNotificationCount", () => { + it.for([ + { read: false, expected: "false" }, + { read: true, expected: "true" }, + ])( + "fetches the count of notifications with read=$read", + async ({ read, expected }, { expect }) => { + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ count: 3 }), + ) + const api = makeNotificationAPI() + const count = runInApp(() => api.useFetchNotificationCount({ read })) + + const result = await count.refresh() + + expect(result.data).toEqual({ count: 3 }) + expect(countCalls).toHaveLength(1) + expect(countCalls[0]?.query).toEqual({ read: expected }) + }, + ) + }) + + describe("markNotificationsRead", () => { + it("marks every cached notification as read when no ids are given", async ({ + expect, + }) => { + const listCalls = mockEndpoint("GET", "/api/notifications", () => + makePage([]), + ) + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ + count: 0, + }), + ) + const putCalls = mockEndpoint( + "PUT", + "/api/notifications/read-status", + () => ({}), + ) + seedPage( + LIST_KEY_A, + makePage([makeNotification("n1", false), makeNotification("n2", true)]), + ) + seedPage(LIST_KEY_B, makePage([makeNotification("n3", false)])) + const api = makeNotificationAPI() + + await api.markNotificationsRead.mutateAsync({ ids: [] }) + + expect(getPage(LIST_KEY_A)?.notifications.map((n) => n.read)).toEqual([ + true, + true, + ]) + expect(getPage(LIST_KEY_B)?.notifications.map((n) => n.read)).toEqual([ + true, + ]) + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual({ ids: [] }) + // the success invalidation only refetches active queries — the + // seeded entries have no query attached, so nothing is fetched + expect(listCalls).toHaveLength(0) + expect(countCalls).toHaveLength(0) + }) + + it("marks only the notifications with the given ids as read", async ({ + expect, + }) => { + const listCalls = mockEndpoint("GET", "/api/notifications", () => + makePage([]), + ) + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ + count: 0, + }), + ) + const putCalls = mockEndpoint( + "PUT", + "/api/notifications/read-status", + () => ({}), + ) + seedPage( + LIST_KEY_A, + makePage([ + makeNotification("n1", false), + makeNotification("n2", false), + ]), + ) + seedPage(LIST_KEY_B, makePage([makeNotification("n3", false)])) + const api = makeNotificationAPI() + + await api.markNotificationsRead.mutateAsync({ ids: ["n1", "n3"] }) + + expect(getPage(LIST_KEY_A)?.notifications.map((n) => n.read)).toEqual([ + true, + false, + ]) + expect(getPage(LIST_KEY_B)?.notifications.map((n) => n.read)).toEqual([ + true, + ]) + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual({ ids: ["n1", "n3"] }) + expect(listCalls).toHaveLength(0) + expect(countCalls).toHaveLength(0) + }) + + it("rolls back the cached pages when the request fails", async ({ + expect, + }) => { + const listCalls = mockEndpoint("GET", "/api/notifications", () => + makePage([]), + ) + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ + count: 0, + }), + ) + const putCalls = mockEndpoint( + "PUT", + "/api/notifications/read-status", + () => { + throw createError({ statusCode: 500 }) + }, + ) + seedPage(LIST_KEY_A, makePage([makeNotification("n1", false)])) + seedPage(LIST_KEY_B, makePage([makeNotification("n2", false)])) + const api = makeNotificationAPI() + + await expect( + api.markNotificationsRead.mutateAsync({ ids: [] }), + ).rejects.toThrow() + + // the rollback writes back the snapshot taken in onMutate, which + // carries the entry key the composable injects for bookkeeping + expect(getPage(LIST_KEY_A)).toEqual({ + ...makePage([makeNotification("n1", false)]), + key: [...LIST_KEY_A], + }) + expect(getPage(LIST_KEY_B)).toEqual({ + ...makePage([makeNotification("n2", false)]), + key: [...LIST_KEY_B], + }) + expect(putCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(countCalls).toHaveLength(0) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + const listCalls = mockEndpoint("GET", "/api/notifications", () => + makePage([]), + ) + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ + count: 0, + }), + ) + const putCalls = mockEndpoint( + "PUT", + "/api/notifications/read-status", + () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }, + ) + seedPage(LIST_KEY_A, makePage([makeNotification("n1", false)])) + seedPage(LIST_KEY_B, makePage([makeNotification("n2", false)])) + const api = makeNotificationAPI() + + const pending = api.markNotificationsRead.mutateAsync({ ids: [] }) + await putReachedSignal + + // the optimistic update landed; divergent data written afterwards + // must survive the failure + expect(getPage(LIST_KEY_A)?.notifications.map((n) => n.read)).toEqual([ + true, + ]) + const divergent = makePage([makeNotification("n9", false)]) + seedPage(LIST_KEY_A, divergent) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(getPage(LIST_KEY_A)).toEqual(divergent) + // the rollback is all-or-nothing, so the untouched page keeps its + // optimistic state too + expect(getPage(LIST_KEY_B)?.notifications.map((n) => n.read)).toEqual([ + true, + ]) + expect(putCalls).toHaveLength(1) + expect(listCalls).toHaveLength(0) + expect(countCalls).toHaveLength(0) + }) + + it("skips cached entries that have no data yet", async ({ expect }) => { + let resolveList: (page: NotificationsResponse) => void = () => undefined + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + // the list load stays pending for the whole mutation, so its cache + // entry exists without data while the optimistic update runs + const listCalls = mockEndpoint("GET", "/api/notifications", () => { + return new Promise((resolve) => { + resolveList = resolve + }) + }) + const countCalls = mockEndpoint( + "GET", + "/api/notifications/count", + () => ({ + count: 0, + }), + ) + const putCalls = mockEndpoint( + "PUT", + "/api/notifications/read-status", + () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }, + ) + seedPage(LIST_KEY_B, makePage([makeNotification("n1", false)])) + const api = makeNotificationAPI() + const list = runInApp(() => + api.useFetchManyNotifications({ limit: 10, page: 1 }), + ) + + const pending = api.markNotificationsRead.mutateAsync({ ids: [] }) + await putReachedSignal + + // reaching the request proves the data-less entry did not abort + // the optimistic update; only the seeded page was touched + expect(getPage(LIST_KEY_A)).toBeUndefined() + expect(getPage(LIST_KEY_B)?.notifications.map((n) => n.read)).toEqual([ + true, + ]) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + // the data-less entry is filtered from the rollback comparison + // too, so the rollback still restores the seeded page — with the + // bookkeeping key the composable injects into its snapshot + expect(getPage(LIST_KEY_B)).toEqual({ + ...makePage([makeNotification("n1", false)]), + key: [...LIST_KEY_B], + }) + expect(putCalls).toHaveLength(1) + expect(listCalls).toHaveLength(1) + expect(countCalls).toHaveLength(0) + + // settle the deferred load so nothing stays in flight + const resolved = makePage([makeNotification("n2", true)]) + resolveList(resolved) + const result = await list.refresh() + expect(result.data).toEqual(resolved) + }) + }) +}) diff --git a/web/app/composables/api/useOrganizationAPI.nuxt.test.ts b/web/app/composables/api/useOrganizationAPI.nuxt.test.ts new file mode 100644 index 00000000..067f86ec --- /dev/null +++ b/web/app/composables/api/useOrganizationAPI.nuxt.test.ts @@ -0,0 +1,128 @@ +import { registerEndpoint } from "@nuxt/test-utils/runtime" +import { setResponseHeader, type H3Event } from "h3" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, + trackEndpointDisposal, +} from "./test-helpers" +import useOrganizationAPI from "./useOrganizationAPI" + +function makeOrganizationAPI() { + return runInApp(() => useOrganizationAPI()) +} + +interface RecordedUpload { + fieldNames: string[] +} + +// registers an upload handler on the test-time h3 app that the real api +// clients route through, recording the multipart field names of each call +// for accounting. The test transport hands the fetch body to the handler +// unserialized (node-mock-http assigns it straight to req.body), so the +// field names are read off the original FormData rather than parsed from +// a wire format. The handler answers with the given location header, or +// with no header at all when location is omitted. +function mockUploadEndpoint(url: string, location?: string): RecordedUpload[] { + const calls: RecordedUpload[] = [] + + const dispose = registerEndpoint(url, { + method: "PUT", + handler: (event: H3Event) => { + const body = (event.node.req as { body?: unknown }).body + + calls.push({ + fieldNames: body instanceof FormData ? [...body.keys()] : [], + }) + + if (location !== undefined) { + setResponseHeader(event, "location", location) + } + + return null + }, + }) + + trackEndpointDisposal(dispose) + + return calls +} + +// creating the composable eagerly loads its query once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic +describe("useOrganizationAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("uploadOrganizationLogo", () => { + it("uploads the logo and returns the stored location", async ({ + expect, + }) => { + const statsCalls = mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/organizations/stats", + () => ({ availableSlots: 3 }), + ) + const uploadCalls = mockUploadEndpoint( + "/api/organizations/logo", + "/files/organization-logo-1", + ) + const api = makeOrganizationAPI() + // settle the eager stats load so its single call is accounted for + await api.fetchOrganizationStats.refresh() + const file = new File(["x"], "x.png", { type: "image/png" }) + + const location = await api.uploadOrganizationLogo.mutateAsync(file) + + expect(location).toBe("/files/organization-logo-1") + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.fieldNames).toEqual(["logo"]) + expect(statsCalls).toHaveLength(1) + }) + + it("rejects when the response has no location header", async ({ + expect, + }) => { + const statsCalls = mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/organizations/stats", + () => ({ availableSlots: 3 }), + ) + const uploadCalls = mockUploadEndpoint("/api/organizations/logo") + const api = makeOrganizationAPI() + // settle the eager stats load so its single call is accounted for + await api.fetchOrganizationStats.refresh() + const file = new File(["x"], "x.png", { type: "image/png" }) + + await expect( + api.uploadOrganizationLogo.mutateAsync(file), + ).rejects.toThrow("missing location header") + + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.fieldNames).toEqual(["logo"]) + expect(statsCalls).toHaveLength(1) + }) + }) + + describe("fetchOrganizationStats", () => { + it("fetches the organization stats", async ({ expect }) => { + const statsCalls = mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/organizations/stats", + () => ({ availableSlots: 3 }), + ) + const api = makeOrganizationAPI() + + const result = await api.fetchOrganizationStats.refresh() + + expect(result.data).toEqual({ availableSlots: 3 }) + expect(statsCalls).toHaveLength(1) + }) + }) +}) diff --git a/web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts b/web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts new file mode 100644 index 00000000..fe56754a --- /dev/null +++ b/web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, +} from "./test-helpers" +import usePrometheusDataSourceAPI from "./usePrometheusDataSourceAPI" + +function makePrometheusDataSourceAPI() { + return runInApp(() => usePrometheusDataSourceAPI()) +} + +const FROM = "2026-01-01T00:00:00.000Z" +const TO = "2026-01-02T00:00:00.000Z" + +const TIME_RANGE = { + from: FROM, + to: TO, + timeRangeKey: "last-24h", +} + +const QUERY_RESULT = { + type: "vector", + result: [{ metric: { job: "web" }, value: [1735689600, "1"] }], +} + +// creating a query with a truthy enabled condition eagerly loads it once; +// refresh() joins that in-flight load (or reuses its fresh result) instead +// of forcing a second request, which keeps the call accounting deterministic +describe("usePrometheusDataSourceAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("usePrometheusQuery", () => { + it("returns null without a query string", async ({ expect }) => { + const queryCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/query", + () => QUERY_RESULT, + ) + const api = makePrometheusDataSourceAPI() + const query = runInApp(() => + api.usePrometheusQuery("ds1", { q: "", ...TIME_RANGE }, true), + ) + + const result = await query.refresh() + + expect(result.data).toBeNull() + expect(queryCalls).toHaveLength(0) + }) + + it("fetches the query result with the time range", async ({ expect }) => { + const queryCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/query", + () => QUERY_RESULT, + ) + const api = makePrometheusDataSourceAPI() + const query = runInApp(() => + api.usePrometheusQuery("ds1", { q: "up", ...TIME_RANGE }, true), + ) + + const result = await query.refresh() + + expect(result.data).toEqual(QUERY_RESULT) + expect(queryCalls).toHaveLength(1) + expect(queryCalls[0]?.query).toEqual({ q: "up", from: FROM, to: TO }) + }) + }) + + describe("usePrometheusMultipleQueries", () => { + it("returns no results without query strings", async ({ expect }) => { + const queryCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/query", + () => QUERY_RESULT, + ) + const api = makePrometheusDataSourceAPI() + const queries = runInApp(() => + api.usePrometheusMultipleQueries( + "ds1", + { queries: [], ...TIME_RANGE }, + true, + ), + ) + + const result = await queries.refresh() + + expect(result.data).toEqual([]) + expect(queryCalls).toHaveLength(0) + }) + + it("fetches one result per query string in order", async ({ expect }) => { + // the response embeds the incoming query string so the resolved + // data proves which request produced which result + const queryCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/query", + (call) => ({ + type: "vector", + result: [{ metric: { q: call.query.q } }], + }), + ) + const api = makePrometheusDataSourceAPI() + const queries = runInApp(() => + api.usePrometheusMultipleQueries( + "ds1", + { queries: ["up", "process_cpu"], ...TIME_RANGE }, + true, + ), + ) + + const result = await queries.refresh() + + expect(result.data).toEqual([ + { type: "vector", result: [{ metric: { q: "up" } }] }, + { type: "vector", result: [{ metric: { q: "process_cpu" } }] }, + ]) + expect(queryCalls).toHaveLength(2) + expect(queryCalls[0]?.query).toEqual({ q: "up", from: FROM, to: TO }) + expect(queryCalls[1]?.query).toEqual({ + q: "process_cpu", + from: FROM, + to: TO, + }) + }) + }) + + describe("usePrometheusMetadata", () => { + it("returns null without a data source id", async ({ expect }) => { + const metadataCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/metadata", + () => ({ result: {} }), + ) + const api = makePrometheusDataSourceAPI() + const metadata = runInApp(() => api.usePrometheusMetadata(null, true)) + + const result = await metadata.refresh() + + expect(result.data).toBeNull() + expect(metadataCalls).toHaveLength(0) + }) + + it("fetches the metric metadata of the data source", async ({ expect }) => { + const metadataResult = { + result: { up: [{ type: "gauge", help: "up", unit: "" }] }, + } + const metadataCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/metadata", + () => metadataResult, + ) + const api = makePrometheusDataSourceAPI() + const metadata = runInApp(() => api.usePrometheusMetadata("ds1", true)) + + const result = await metadata.refresh() + + expect(result.data).toEqual(metadataResult) + expect(metadataCalls).toHaveLength(1) + expect(metadataCalls[0]?.query).toEqual({}) + }) + }) + + describe("usePrometheusLabels", () => { + it("returns null without matchers", async ({ expect }) => { + const labelCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/labels", + () => ({ result: [] }), + ) + const api = makePrometheusDataSourceAPI() + const labels = runInApp(() => + api.usePrometheusLabels("ds1", { ...TIME_RANGE }, true), + ) + + const result = await labels.refresh() + + expect(result.data).toBeNull() + expect(labelCalls).toHaveLength(0) + }) + + it("fetches the label names matching the matchers", async ({ expect }) => { + const labelCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/labels", + () => ({ result: ["job", "instance"] }), + ) + const api = makePrometheusDataSourceAPI() + const labels = runInApp(() => + api.usePrometheusLabels( + "ds1", + { matchers: ["up", "node_load1"], ...TIME_RANGE }, + true, + ), + ) + + const result = await labels.refresh() + + expect(result.data).toEqual({ result: ["job", "instance"] }) + expect(labelCalls).toHaveLength(1) + // the matchers parameter is repeated, so getQuery yields an array + expect(labelCalls[0]?.query).toEqual({ + from: FROM, + to: TO, + matchers: ["up", "node_load1"], + }) + }) + }) + + describe("usePrometheusLabelValues", () => { + it("returns null without a label", async ({ expect }) => { + const valueCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/labels/job/values", + () => ({ result: [] }), + ) + const api = makePrometheusDataSourceAPI() + const values = runInApp(() => + api.usePrometheusLabelValues("ds1", { label: "", ...TIME_RANGE }, true), + ) + + const result = await values.refresh() + + expect(result.data).toBeNull() + expect(valueCalls).toHaveLength(0) + }) + + it("fetches the values of the label", async ({ expect }) => { + const valueCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/labels/job/values", + () => ({ result: ["web", "worker"] }), + ) + const api = makePrometheusDataSourceAPI() + const values = runInApp(() => + api.usePrometheusLabelValues( + "ds1", + { label: "job", ...TIME_RANGE }, + true, + ), + ) + + const result = await values.refresh() + + expect(result.data).toEqual({ result: ["web", "worker"] }) + expect(valueCalls).toHaveLength(1) + expect(valueCalls[0]?.query).toEqual({ from: FROM, to: TO }) + }) + + it("fetches the values of the label matching the matchers", async ({ + expect, + }) => { + const valueCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/labels/job/values", + () => ({ result: ["web"] }), + ) + const api = makePrometheusDataSourceAPI() + const values = runInApp(() => + api.usePrometheusLabelValues( + "ds1", + { label: "job", matchers: ["up", "node_load1"], ...TIME_RANGE }, + true, + ), + ) + + const result = await values.refresh() + + expect(result.data).toEqual({ result: ["web"] }) + expect(valueCalls).toHaveLength(1) + // the matchers parameter is repeated, so getQuery yields an array + expect(valueCalls[0]?.query).toEqual({ + from: FROM, + to: TO, + matchers: ["up", "node_load1"], + }) + }) + }) + + describe("usePrometheusSeries", () => { + it("returns null without matchers", async ({ expect }) => { + const seriesCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/series", + () => ({ result: [] }), + ) + const api = makePrometheusDataSourceAPI() + const series = runInApp(() => + api.usePrometheusSeries("ds1", { matchers: [], ...TIME_RANGE }, true), + ) + + const result = await series.refresh() + + expect(result.data).toBeNull() + expect(seriesCalls).toHaveLength(0) + }) + + it("fetches the series matching the matchers", async ({ expect }) => { + const seriesResult = { + result: [{ __name__: "up", job: "web" }], + } + const seriesCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/prometheus/series", + () => seriesResult, + ) + const api = makePrometheusDataSourceAPI() + const series = runInApp(() => + api.usePrometheusSeries( + "ds1", + { matchers: ["up", "node_load1"], ...TIME_RANGE }, + true, + ), + ) + + const result = await series.refresh() + + expect(result.data).toEqual(seriesResult) + expect(seriesCalls).toHaveLength(1) + // the matchers parameter is repeated, so getQuery yields an array + expect(seriesCalls[0]?.query).toEqual({ + from: FROM, + to: TO, + matchers: ["up", "node_load1"], + }) + }) + }) +}) diff --git a/web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts b/web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts new file mode 100644 index 00000000..ded60860 --- /dev/null +++ b/web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, +} from "./test-helpers" +import useSQLDataSourceAPI from "./useSQLDataSourceAPI" + +function makeSQLDataSourceAPI() { + return runInApp(() => useSQLDataSourceAPI()) +} + +const METADATA_RESULT = { + tables: { "public.users": { columns: [{ name: "id" }] } }, + defaultSchema: "public", +} + +// creating a query with a truthy enabled condition eagerly loads it once; +// refresh() joins that in-flight load (or reuses its fresh result) instead +// of forcing a second request, which keeps the call accounting deterministic +describe("useSQLDataSourceAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("useSQLMetadata", () => { + it("returns null without a data source id", async ({ expect }) => { + const metadataCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/sql/metadata", + () => METADATA_RESULT, + ) + const api = makeSQLDataSourceAPI() + const metadata = runInApp(() => api.useSQLMetadata(null, true)) + + const result = await metadata.refresh() + + expect(result.data).toBeNull() + expect(metadataCalls).toHaveLength(0) + }) + + it("fetches the sql metadata of the data source", async ({ expect }) => { + const metadataCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/sql/metadata", + () => METADATA_RESULT, + ) + const api = makeSQLDataSourceAPI() + const metadata = runInApp(() => api.useSQLMetadata("ds1", true)) + + const result = await metadata.refresh() + + expect(result.data).toEqual(METADATA_RESULT) + expect(metadataCalls).toHaveLength(1) + expect(metadataCalls[0]?.query).toEqual({}) + }) + }) + + describe("useSQLLabels", () => { + it("returns null without a query string", async ({ expect }) => { + const labelCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/sql/query-labels", + () => ({ labels: {} }), + ) + const api = makeSQLDataSourceAPI() + const labels = runInApp(() => + api.useSQLLabels( + "ds1", + { + q: "", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + timeRangeKey: "last-24h", + }, + true, + ), + ) + + const result = await labels.refresh() + + expect(result.data).toBeNull() + expect(labelCalls).toHaveLength(0) + }) + + it("fetches the query labels with the time range", async ({ expect }) => { + const labelCalls = mockEndpoint( + "GET", + "/api/data-sources/ds1/sql/query-labels", + () => ({ labels: { hostname: "web-1" } }), + ) + const api = makeSQLDataSourceAPI() + const labels = runInApp(() => + api.useSQLLabels( + "ds1", + { + q: "SELECT * FROM metrics", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + timeRangeKey: "last-24h", + }, + true, + ), + ) + + const result = await labels.refresh() + + expect(result.data).toEqual({ labels: { hostname: "web-1" } }) + expect(labelCalls).toHaveLength(1) + // timeRangeKey is a local cache key only — it is never sent + expect(labelCalls[0]?.query).toEqual({ + q: "SELECT * FROM metrics", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + }) + }) + }) +}) diff --git a/web/app/composables/api/useSlackAPI.nuxt.test.ts b/web/app/composables/api/useSlackAPI.nuxt.test.ts new file mode 100644 index 00000000..0aa56065 --- /dev/null +++ b/web/app/composables/api/useSlackAPI.nuxt.test.ts @@ -0,0 +1,559 @@ +import type { FetchError } from "ofetch" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, + seedQueryData, +} from "./test-helpers" +import useSlackAPI from "./useSlackAPI" + +const CONNECTED_KEY = ["slack", "connected"] as const +const SETTINGS_KEY = ["slack", "user-link-settings"] as const + +function makeSlackAPI() { + return runInApp(() => useSlackAPI()) +} + +// creating the composable eagerly loads its queries once; refresh() joins +// that in-flight load (or reuses its fresh result) instead of forcing a +// second request, which keeps the call accounting deterministic. The +// settings query also refreshes the connection status internally, and that +// refresh dedupes into the same eager status load. +describe("useSlackAPI", { concurrent: false }, () => { + // the tests share the app-wide query cache and the test-time endpoint + // registry, so they cannot interleave + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("fetchSlackConnectionStatus", () => { + it("fetches the connection status", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const api = makeSlackAPI() + + const result = await api.fetchSlackConnectionStatus.refresh() + + expect(result.data).toEqual({ connected: true, configured: true }) + expect(statusCalls).toHaveLength(1) + }) + }) + + describe("slackConfigured", () => { + it("reports configured while the status is unknown", ({ expect }) => { + const api = makeSlackAPI() + + expect(api.slackConfigured.value).toBe(true) + }) + + it("reports unconfigured once the status says so", async ({ expect }) => { + mockEndpoint("GET", "/api/slack", () => ({ + connected: false, + configured: false, + })) + const api = makeSlackAPI() + + await api.fetchSlackConnectionStatus.refresh() + + expect(api.slackConfigured.value).toBe(false) + }) + }) + + describe("fetchSlackInstallURL", () => { + it("fetches the install url", async ({ expect }) => { + const installCalls = mockEndpoint("GET", "/api/slack/install", () => ({ + url: "https://slack.test/i", + })) + const api = makeSlackAPI() + + const result = await api.fetchSlackInstallURL() + + expect(result).toEqual({ url: "https://slack.test/i" }) + expect(installCalls).toHaveLength(1) + }) + }) + + describe("connectSlack", () => { + it("connects with the callback query parameters", async ({ expect }) => { + const connectCalls = mockEndpoint("GET", "/api/slack/connect", () => ({})) + const api = makeSlackAPI() + + await api.connectSlack.mutateAsync(new URLSearchParams({ code: "c1" })) + + expect(connectCalls).toHaveLength(1) + expect(connectCalls[0]?.query).toEqual({ code: "c1" }) + }) + + it("connects without query parameters", async ({ expect }) => { + const connectCalls = mockEndpoint("GET", "/api/slack/connect", () => ({})) + const api = makeSlackAPI() + + await api.connectSlack.mutateAsync(new URLSearchParams()) + + expect(connectCalls).toHaveLength(1) + expect(connectCalls[0]?.query).toEqual({}) + }) + }) + + describe("linkSlackUser", () => { + it("links the user with the callback query parameters", async ({ + expect, + }) => { + const linkCalls = mockEndpoint("GET", "/api/slack/users/link", () => ({ + linked: true, + })) + const api = makeSlackAPI() + + const result = await api.linkSlackUser.mutateAsync( + new URLSearchParams({ code: "c1" }), + ) + + expect(result).toEqual({ linked: true }) + expect(linkCalls).toHaveLength(1) + expect(linkCalls[0]?.query).toEqual({ code: "c1" }) + }) + + it("links the user without query parameters", async ({ expect }) => { + const linkCalls = mockEndpoint("GET", "/api/slack/users/link", () => ({ + linked: false, + })) + const api = makeSlackAPI() + + const result = await api.linkSlackUser.mutateAsync(new URLSearchParams()) + + expect(result).toEqual({ linked: false }) + expect(linkCalls).toHaveLength(1) + expect(linkCalls[0]?.query).toEqual({}) + }) + }) + + describe("disconnectSlack", () => { + it("disconnects and refreshes the connection status", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const deleteCalls = mockEndpoint("DELETE", "/api/slack", () => ({})) + const api = makeSlackAPI() + await api.fetchSlackConnectionStatus.refresh() + + await api.disconnectSlack.mutateAsync() + + expect(deleteCalls).toHaveLength(1) + // the success invalidation refetches the active status query + expect(statusCalls).toHaveLength(2) + expect(api.fetchSlackConnectionStatus.data.value).toEqual({ + connected: true, + configured: true, + }) + }) + + it("rolls back the optimistic disconnect when the request fails", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("DELETE", "/api/slack", () => { + throw createError({ statusCode: 500 }) + }) + const api = makeSlackAPI() + await api.fetchSlackConnectionStatus.refresh() + + await expect(api.disconnectSlack.mutateAsync()).rejects.toThrow() + + expect(statusCalls).toHaveLength(1) + expect(api.fetchSlackConnectionStatus.data.value).toEqual({ + connected: true, + configured: true, + }) + }) + + it("skips the rollback when the cache changed after the optimistic disconnect", async ({ + expect, + }) => { + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("DELETE", "/api/slack", () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeSlackAPI() + await api.fetchSlackConnectionStatus.refresh() + + const pending = api.disconnectSlack.mutateAsync() + await deleteReachedSignal + + // the optimistic update landed; divergent data written afterwards + // must survive the failure + expect(api.fetchSlackConnectionStatus.data.value).toEqual({ + connected: false, + configured: true, + }) + seedQueryData(CONNECTED_KEY, { + connected: true, + configured: false, + }) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(api.fetchSlackConnectionStatus.data.value).toEqual({ + connected: true, + configured: false, + }) + }) + }) + + describe("fetchSlackUserLinkSettings", () => { + it("returns null when slack is not configured", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: false, + configured: false, + })) + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: true, + })) + const api = makeSlackAPI() + + const result = await api.fetchSlackUserLinkSettings.refresh() + + expect(result.data).toBeNull() + expect(usersCalls).toHaveLength(0) + expect(statusCalls).toHaveLength(1) + }) + + it("fetches the settings when slack is configured", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: true, + })) + const api = makeSlackAPI() + + const result = await api.fetchSlackUserLinkSettings.refresh() + + expect(result.data).toEqual({ notifications: true }) + expect(usersCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + + it("returns null when the user has no slack link", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => { + throw createError({ statusCode: 404 }) + }) + const api = makeSlackAPI() + + const result = await api.fetchSlackUserLinkSettings.refresh() + + expect(result.data).toBeNull() + expect(usersCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + + it("errors when the settings request fails", async ({ expect }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const successCalls = mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: true, + })) + const api = makeSlackAPI() + // a failing query cannot dedupe the eager creation load into the + // explicit act, so the entry is warmed with a success first and + // the failure is a single forced refetch through a later-wins + // handler registration + await api.fetchSlackUserLinkSettings.refresh() + const failedCalls = mockEndpoint("GET", "/api/slack/users", () => { + throw createError({ statusCode: 500 }) + }) + + await api.fetchSlackUserLinkSettings.refetch() + + const error = api.fetchSlackUserLinkSettings.error + .value as FetchError | null + expect(error?.statusCode).toBe(500) + // a non-404 failure keeps the previously fetched settings, unlike + // the 404 branch that resolves them to null + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: true, + }) + expect(successCalls).toHaveLength(1) + // ofetch retries a failed GET once by default on a 500 response + expect(failedCalls).toHaveLength(2) + expect(statusCalls).toHaveLength(1) + }) + }) + + describe("updateSlackUserLinkSettings", () => { + it("optimistically applies the settings and refetches them after success", async ({ + expect, + }) => { + let resolvePut: (value: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + let serverSettings = { notifications: false } + const usersCalls = mockEndpoint( + "GET", + "/api/slack/users", + () => serverSettings, + ) + const putCalls = mockEndpoint( + "PUT", + "/api/slack/users/settings", + (call) => { + serverSettings = call.body as { notifications: boolean } + putReached() + + return new Promise((resolve) => { + resolvePut = resolve + }) + }, + ) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + const pending = api.updateSlackUserLinkSettings.mutateAsync({ + notifications: true, + }) + await putReachedSignal + + // the optimistic update lands before the request resolves + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: true, + }) + resolvePut(serverSettings) + + await pending + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: true, + }) + expect(putCalls).toHaveLength(1) + expect(putCalls[0]?.body).toEqual({ notifications: true }) + // the success invalidation refetches the active settings query; + // the connection status stays fresh and is not refetched + expect(usersCalls).toHaveLength(2) + expect(statusCalls).toHaveLength(1) + }) + + it("rolls back the optimistic settings when the request fails", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: false, + })) + const putCalls = mockEndpoint("PUT", "/api/slack/users/settings", () => { + throw createError({ statusCode: 500 }) + }) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + await expect( + api.updateSlackUserLinkSettings.mutateAsync({ notifications: true }), + ).rejects.toThrow() + + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: false, + }) + expect(putCalls).toHaveLength(1) + expect(usersCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + + it("skips the rollback when the cache changed after the optimistic update", async ({ + expect, + }) => { + let rejectPut: (err: unknown) => void = () => undefined + let putReached: () => void = () => undefined + const putReachedSignal = new Promise((resolve) => { + putReached = resolve + }) + + mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: false, + })) + mockEndpoint("PUT", "/api/slack/users/settings", () => { + putReached() + + return new Promise((_resolve, reject) => { + rejectPut = reject + }) + }) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + const pending = api.updateSlackUserLinkSettings.mutateAsync({ + notifications: true, + }) + await putReachedSignal + + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: true, + }) + // divergent data written after the optimistic update must survive + // the failure; null is the only cache value distinguishable from + // both the optimistic and the original settings + seedQueryData(SETTINGS_KEY, null) + rejectPut(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(api.fetchSlackUserLinkSettings.data.value).toBeNull() + }) + }) + + describe("unlinkSlackUser", () => { + it("optimistically clears the settings and refetches them after success", async ({ + expect, + }) => { + let resolveDelete: (value: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + let linked = true + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => { + if (!linked) { + throw createError({ statusCode: 404 }) + } + + return { notifications: true } + }) + const deleteCalls = mockEndpoint("DELETE", "/api/slack/users", () => { + linked = false + deleteReached() + + return new Promise((resolve) => { + resolveDelete = resolve + }) + }) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + const pending = api.unlinkSlackUser.mutateAsync() + await deleteReachedSignal + + // the optimistic clear lands before the request resolves + expect(api.fetchSlackUserLinkSettings.data.value).toBeNull() + resolveDelete({}) + + await pending + // the success invalidation refetches the active settings query, + // which now resolves the unlinked 404 into null + expect(api.fetchSlackUserLinkSettings.data.value).toBeNull() + expect(deleteCalls).toHaveLength(1) + expect(usersCalls).toHaveLength(2) + expect(statusCalls).toHaveLength(1) + }) + + it("rolls back the cleared settings when the request fails", async ({ + expect, + }) => { + const statusCalls = mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + const usersCalls = mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: true, + })) + const deleteCalls = mockEndpoint("DELETE", "/api/slack/users", () => { + throw createError({ statusCode: 500 }) + }) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + await expect(api.unlinkSlackUser.mutateAsync()).rejects.toThrow() + + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: true, + }) + expect(deleteCalls).toHaveLength(1) + expect(usersCalls).toHaveLength(1) + expect(statusCalls).toHaveLength(1) + }) + + it("skips the rollback when the cache changed after the optimistic clear", async ({ + expect, + }) => { + let rejectDelete: (err: unknown) => void = () => undefined + let deleteReached: () => void = () => undefined + const deleteReachedSignal = new Promise((resolve) => { + deleteReached = resolve + }) + + mockEndpoint("GET", "/api/slack", () => ({ + connected: true, + configured: true, + })) + mockEndpoint("GET", "/api/slack/users", () => ({ + notifications: true, + })) + mockEndpoint("DELETE", "/api/slack/users", () => { + deleteReached() + + return new Promise((_resolve, reject) => { + rejectDelete = reject + }) + }) + const api = makeSlackAPI() + await api.fetchSlackUserLinkSettings.refresh() + + const pending = api.unlinkSlackUser.mutateAsync() + await deleteReachedSignal + + expect(api.fetchSlackUserLinkSettings.data.value).toBeNull() + // divergent data written after the optimistic clear must survive + // the failure + seedQueryData(SETTINGS_KEY, { notifications: false }) + rejectDelete(createError({ statusCode: 500 })) + + await expect(pending).rejects.toThrow() + expect(api.fetchSlackUserLinkSettings.data.value).toEqual({ + notifications: false, + }) + }) + }) +}) diff --git a/web/app/composables/api/useUserAPI.nuxt.test.ts b/web/app/composables/api/useUserAPI.nuxt.test.ts new file mode 100644 index 00000000..46a17dd9 --- /dev/null +++ b/web/app/composables/api/useUserAPI.nuxt.test.ts @@ -0,0 +1,92 @@ +import { registerEndpoint } from "@nuxt/test-utils/runtime" +import { setResponseHeader, type H3Event } from "h3" +import { afterEach, beforeEach, describe, it } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + runInApp, + trackEndpointDisposal, +} from "./test-helpers" +import useUserAPI from "./useUserAPI" + +function makeUserAPI() { + return runInApp(() => useUserAPI()) +} + +interface RecordedUpload { + fieldNames: string[] +} + +// registers an upload handler on the test-time h3 app that the real api +// clients route through, recording the multipart field names of each call +// for accounting. The test transport hands the fetch body to the handler +// unserialized (node-mock-http assigns it straight to req.body), so the +// field names are read off the original FormData rather than parsed from +// a wire format. The handler answers with the given location header, or +// with no header at all when location is omitted. +function mockUploadEndpoint(url: string, location?: string): RecordedUpload[] { + const calls: RecordedUpload[] = [] + + const dispose = registerEndpoint(url, { + method: "PUT", + handler: (event: H3Event) => { + const body = (event.node.req as { body?: unknown }).body + + calls.push({ + fieldNames: body instanceof FormData ? [...body.keys()] : [], + }) + + if (location !== undefined) { + setResponseHeader(event, "location", location) + } + + return null + }, + }) + + trackEndpointDisposal(dispose) + + return calls +} + +// the tests share the app-wide query cache and the test-time endpoint +// registry, so they cannot interleave +describe("useUserAPI", { concurrent: false }, () => { + beforeEach(clearQueryCache) + + afterEach(disposeMockEndpoints) + + describe("uploadUserImage", () => { + it("uploads the image and returns the stored location", async ({ + expect, + }) => { + const uploadCalls = mockUploadEndpoint( + "/api/users/image", + "/files/user-image-1", + ) + const api = makeUserAPI() + const file = new File(["x"], "x.png", { type: "image/png" }) + + const location = await api.uploadUserImage.mutateAsync(file) + + expect(location).toBe("/files/user-image-1") + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.fieldNames).toEqual(["image"]) + }) + + it("rejects when the response has no location header", async ({ + expect, + }) => { + const uploadCalls = mockUploadEndpoint("/api/users/image") + const api = makeUserAPI() + const file = new File(["x"], "x.png", { type: "image/png" }) + + await expect(api.uploadUserImage.mutateAsync(file)).rejects.toThrow( + "missing location header", + ) + + expect(uploadCalls).toHaveLength(1) + expect(uploadCalls[0]?.fieldNames).toEqual(["image"]) + }) + }) +}) diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index 0c648216..401eb8dc 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -230,6 +230,7 @@ export default defineNuxtConfig({ org: "oxynote", project: "bifrost", authToken: process.env.SENTRY_AUTH_TOKEN, + telemetry: false, }, // hidden sourcemaps exist only for the Sentry upload. Skip generating diff --git a/web/vitest.config.ts b/web/vitest.config.ts index dc5a1995..dc18ae94 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -82,16 +82,19 @@ export default defineConfig({ // vendored, regenerated by the shadcn CLI "app/components/shadcn/**", "**/*.d.ts", + // test-only helper modules colocated with the suites they + // serve — test code, not app code + "app/**/test-helpers.ts", ], // set from the measured baseline and ratcheted up as suites // grow — never lowered. 100% is not the target: genuinely // untestable branches stay visible in the report, marked with // NOCOV comments in place thresholds: { - statements: 19, - branches: 11, - functions: 14, - lines: 19, + statements: 27, + branches: 19, + functions: 22, + lines: 27, }, },