mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add api composable tests in web
This commit is contained in:
parent
3a901b5311
commit
e3363c2ee6
|
|
@ -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
|
||||
|
|
|
|||
97
web/app/composables/api/file-utils.test.ts
Normal file
97
web/app/composables/api/file-utils.test.ts
Normal 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`,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
224
web/app/composables/api/prometheus-utils.test.ts
Normal file
224
web/app/composables/api/prometheus-utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
159
web/app/composables/api/test-helpers.ts
Normal file
159
web/app/composables/api/test-helpers.ts
Normal 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
|
||||
}
|
||||
39
web/app/composables/api/useAuthAPI.nuxt.test.ts
Normal file
39
web/app/composables/api/useAuthAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
830
web/app/composables/api/useDataSourceAPI.nuxt.test.ts
Normal file
830
web/app/composables/api/useDataSourceAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
1773
web/app/composables/api/useDocumentAPI.nuxt.test.ts
Normal file
1773
web/app/composables/api/useDocumentAPI.nuxt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1344
web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts
Normal file
1344
web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
123
web/app/composables/api/useDocumentFileAPI.nuxt.test.ts
Normal file
123
web/app/composables/api/useDocumentFileAPI.nuxt.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
699
web/app/composables/api/useDocumentHookAPI.nuxt.test.ts
Normal file
699
web/app/composables/api/useDocumentHookAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
393
web/app/composables/api/useGitHubAPI.nuxt.test.ts
Normal file
393
web/app/composables/api/useGitHubAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
361
web/app/composables/api/useNotificationAPI.nuxt.test.ts
Normal file
361
web/app/composables/api/useNotificationAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
128
web/app/composables/api/useOrganizationAPI.nuxt.test.ts
Normal file
128
web/app/composables/api/useOrganizationAPI.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
333
web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts
Normal file
333
web/app/composables/api/usePrometheusDataSourceAPI.nuxt.test.ts
Normal 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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
121
web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts
Normal file
121
web/app/composables/api/useSQLDataSourceAPI.nuxt.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
559
web/app/composables/api/useSlackAPI.nuxt.test.ts
Normal file
559
web/app/composables/api/useSlackAPI.nuxt.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
92
web/app/composables/api/useUserAPI.nuxt.test.ts
Normal file
92
web/app/composables/api/useUserAPI.nuxt.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user