add api composable tests in web

This commit is contained in:
swithek 2026-08-19 18:30:37 +02:00
parent 3a901b5311
commit e3363c2ee6
19 changed files with 7291 additions and 4 deletions

View File

@ -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

View File

@ -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`,
)
},
)
})

View File

@ -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<typeof PrometheusDataSourceClient>[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)
})
})
})

View File

@ -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<T>(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<string, unknown>
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<void>((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
}

View File

@ -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)
})
})
})

View File

@ -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> = {}): 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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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)
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<string, unknown>
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"])
})
})
})

View File

@ -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)
})
})
})

View File

@ -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<void>((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)
})
})
})

View File

@ -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<void>((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<void>((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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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"],
})
})
})
})

View File

@ -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",
})
})
})
})

View File

@ -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<void>((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<void>((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<void>((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<void>((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<void>((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,
})
})
})
})

View File

@ -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"])
})
})
})

View File

@ -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

View File

@ -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,
},
},