mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-18 03:19:19 +02:00
1171 lines
31 KiB
TypeScript
1171 lines
31 KiB
TypeScript
import { flushPromises } from "@vue/test-utils"
|
|
import { afterEach, beforeEach, describe, it } from "vitest"
|
|
import type {
|
|
VariableGroupTreeElement,
|
|
VariableRequest,
|
|
VariableTreeElement,
|
|
VariableTreeResponse,
|
|
} from "~/utils"
|
|
import {
|
|
ANY_STRING,
|
|
clearQueryCache,
|
|
disposeMockEndpoints,
|
|
makeXid,
|
|
mockDeferredEndpoint,
|
|
mockEndpoint,
|
|
readQueryData,
|
|
runInApp,
|
|
seedAuthOrganization,
|
|
seedAuthSession,
|
|
seedQueryData,
|
|
} from "./test-helpers"
|
|
import useVariableAPI from "./useVariableAPI"
|
|
|
|
const TREE_KEY = ["variables", "tree"] as const
|
|
const VARIABLES_URL = "/api/variables"
|
|
const TREE_URL = "/api/variables/tree"
|
|
|
|
const ORG_ID = "org-1"
|
|
const USER_ID = "user-1"
|
|
const GROUP_BUILTIN = makeXid("grpbuiltin")
|
|
const GROUP_A = makeXid("grpa")
|
|
const GROUP_B = makeXid("grpb")
|
|
const GROUP_MISSING = makeXid("grpmissing")
|
|
const VAR_LIST = makeXid("varlist")
|
|
const VAR_TEXT = makeXid("vartext")
|
|
const VAR_MISSING = makeXid("varmissing")
|
|
const OPTION_A = makeXid("opta")
|
|
const OPTION_B = makeXid("optb")
|
|
const OPTION_MISSING = makeXid("optmissing")
|
|
const SHORT_ID = "local-1"
|
|
|
|
const LIST_REQ: VariableRequest = {
|
|
groupId: GROUP_A,
|
|
key: "env",
|
|
name: "Environment",
|
|
description: null,
|
|
type: "list",
|
|
options: [{ id: OPTION_A, value: "production" }, { value: "staging" }],
|
|
}
|
|
|
|
const TEXT_REQ: VariableRequest = {
|
|
groupId: GROUP_A,
|
|
key: "region",
|
|
name: "Primary region",
|
|
description: "Where the stack runs",
|
|
type: "text",
|
|
defaultValue: "eu-central-1",
|
|
}
|
|
|
|
type VariableAPI = ReturnType<typeof useVariableAPI>
|
|
|
|
// every mutation writes the tree through one cache entry and invalidates it
|
|
// on success, so the tests share a cache and cannot interleave
|
|
describe("useVariableAPI", { concurrent: false }, () => {
|
|
beforeEach(clearQueryCache)
|
|
afterEach(disposeMockEndpoints)
|
|
|
|
describe("fetchVariables", () => {
|
|
it("fetches the groups with their variables", async ({ expect }) => {
|
|
const calls = mockEndpoint("GET", TREE_URL, () => makeTree())
|
|
const api = makeVariableAPI()
|
|
|
|
await api.fetchVariables.refresh()
|
|
|
|
expect.soft(calls).toHaveLength(1)
|
|
expect.soft(api.fetchVariables.data.value).toEqual(makeTree())
|
|
})
|
|
|
|
it("exposes the error when the request fails", async ({ expect }) => {
|
|
const calls = mockEndpoint("GET", TREE_URL, () => {
|
|
throw createError({ statusCode: 400 })
|
|
})
|
|
const api = makeVariableAPI()
|
|
|
|
await api.fetchVariables.refresh()
|
|
|
|
expect.soft(calls).toHaveLength(1)
|
|
expect.soft(api.fetchVariables.error.value).toBeInstanceOf(Error)
|
|
expect.soft(api.fetchVariables.data.value).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("createVariable", () => {
|
|
it("shows the new variable at the end of its group and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const post = mockSnapshotEndpoint(
|
|
"POST",
|
|
VARIABLES_URL,
|
|
makeListVariable(),
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
const created = await api.createVariable.mutateAsync(LIST_REQ)
|
|
await flushPromises()
|
|
|
|
const optimistic = groupOf(post.snapshots[0], GROUP_A)
|
|
expect(optimistic?.variables.map((v) => v.id)).toEqual([
|
|
VAR_LIST,
|
|
VAR_TEXT,
|
|
ANY_STRING,
|
|
])
|
|
expect(optimistic?.variables[2]).toMatchObject({
|
|
organizationId: ORG_ID,
|
|
groupId: GROUP_A,
|
|
key: "env",
|
|
type: "list",
|
|
defaultValue: OPTION_A,
|
|
value: null,
|
|
createdBy: USER_ID,
|
|
localOptimisticInsert: true,
|
|
})
|
|
expect(
|
|
optimistic?.variables[2]?.options.map((o) => [o.id, o.value]),
|
|
).toEqual([
|
|
[OPTION_A, "production"],
|
|
[ANY_STRING, "staging"],
|
|
])
|
|
expect.soft(created).toEqual(makeListVariable())
|
|
expect.soft(post.calls).toHaveLength(1)
|
|
expect.soft(post.calls[0]?.body).toEqual(LIST_REQ)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
expect.soft(readTree()).toEqual([])
|
|
})
|
|
|
|
it("sends the create without an optimistic insert when session data is missing", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const post = mockSnapshotEndpoint(
|
|
"POST",
|
|
VARIABLES_URL,
|
|
makeListVariable(),
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await api.createVariable.mutateAsync(LIST_REQ)
|
|
await flushPromises()
|
|
|
|
expect.soft(post.snapshots[0]).toEqual(makeTree())
|
|
expect.soft(post.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("rejects without a request when the group is not in the tree", async ({
|
|
expect,
|
|
}) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const postCalls = mockEndpoint("POST", VARIABLES_URL, () => ({}))
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.createVariable.mutateAsync({ ...LIST_REQ, groupId: GROUP_MISSING }),
|
|
).rejects.toThrow("invalid variable data")
|
|
|
|
expect.soft(postCalls).toHaveLength(0)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
|
|
it("drops the new variable again when the request fails", async ({
|
|
expect,
|
|
}) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const postCalls = mockFailingEndpoint("POST", VARIABLES_URL)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(api.createVariable.mutateAsync(LIST_REQ)).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(postCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("moveVariable", () => {
|
|
it.for([
|
|
{
|
|
name: "moves a variable into another group",
|
|
input: { id: VAR_TEXT, groupId: GROUP_B, sortIndex: 0 },
|
|
expected: { [GROUP_A]: [VAR_LIST], [GROUP_B]: [VAR_TEXT] },
|
|
},
|
|
{
|
|
name: "moves a variable inside its own group",
|
|
input: { id: VAR_TEXT, groupId: GROUP_A, sortIndex: 0 },
|
|
expected: { [GROUP_A]: [VAR_TEXT, VAR_LIST], [GROUP_B]: [] },
|
|
},
|
|
])(
|
|
"$name and refreshes the tree",
|
|
async ({ input, expected }, { expect }) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint("PUT", TREE_URL)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariable.mutateAsync(input),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
const snapshot = put.snapshots[0]
|
|
expect({
|
|
[GROUP_A]: variableIds(snapshot, GROUP_A),
|
|
[GROUP_B]: variableIds(snapshot, GROUP_B),
|
|
}).toEqual(expected)
|
|
expect(findVariable(snapshot, VAR_TEXT)?.groupId).toBe(input.groupId)
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual(input)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
},
|
|
)
|
|
|
|
it.for([
|
|
{ name: "variable", input: { id: VAR_MISSING, groupId: GROUP_A } },
|
|
{ name: "group", input: { id: VAR_TEXT, groupId: GROUP_MISSING } },
|
|
])(
|
|
"rejects without a request when the $name is not in the tree",
|
|
async ({ input }, { expect }) => {
|
|
const putCalls = mockEndpoint("PUT", TREE_URL, () => ({}))
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariable.mutateAsync({ ...input, sortIndex: 0 }),
|
|
).rejects.toThrow("invalid variable move data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
},
|
|
)
|
|
|
|
it("puts the old order back when the move fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint("PUT", TREE_URL)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariable.mutateAsync({
|
|
id: VAR_TEXT,
|
|
groupId: GROUP_B,
|
|
sortIndex: 0,
|
|
}),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("updateVariable", () => {
|
|
it("rewrites the variable in place and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint("PUT", `/api/variables/${VAR_TEXT}`)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariable.mutateAsync({ id: VAR_TEXT, req: TEXT_REQ }),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
const snapshot = put.snapshots[0]
|
|
expect(variableIds(snapshot, GROUP_A)).toEqual([VAR_LIST, VAR_TEXT])
|
|
expect(findVariable(snapshot, VAR_TEXT)).toEqual({
|
|
...makeTextVariable(),
|
|
key: "region",
|
|
name: "Primary region",
|
|
description: "Where the stack runs",
|
|
defaultValue: "eu-central-1",
|
|
})
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual(TEXT_REQ)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("moves the variable to the end of another group", async ({ expect }) => {
|
|
mockTree()
|
|
const put = mockSnapshotEndpoint("PUT", `/api/variables/${VAR_LIST}`)
|
|
const api = makeVariableAPI()
|
|
|
|
await api.updateVariable.mutateAsync({
|
|
id: VAR_LIST,
|
|
req: { ...LIST_REQ, groupId: GROUP_B },
|
|
})
|
|
|
|
const snapshot = put.snapshots[0]
|
|
expect.soft(variableIds(snapshot, GROUP_A)).toEqual([VAR_TEXT])
|
|
expect.soft(variableIds(snapshot, GROUP_B)).toEqual([VAR_LIST])
|
|
expect.soft(findVariable(snapshot, VAR_LIST)?.groupId).toBe(GROUP_B)
|
|
})
|
|
|
|
it.for([
|
|
{
|
|
name: "keeps the value of a text variable",
|
|
input: { id: VAR_TEXT, req: TEXT_REQ },
|
|
expected: { value: "us-east-1", defaultValue: "eu-central-1" },
|
|
},
|
|
{
|
|
name: "drops the value when the type changes",
|
|
input: { id: VAR_TEXT, req: { ...TEXT_REQ, type: "number" } },
|
|
expected: { value: null, defaultValue: "eu-central-1" },
|
|
},
|
|
{
|
|
name: "keeps a picked option that stays",
|
|
input: {
|
|
id: VAR_LIST,
|
|
req: {
|
|
...LIST_REQ,
|
|
options: [
|
|
{ id: OPTION_B, value: "staging" },
|
|
{ id: OPTION_A, value: "production" },
|
|
],
|
|
},
|
|
},
|
|
expected: { value: OPTION_B, defaultValue: OPTION_B },
|
|
},
|
|
{
|
|
name: "drops a picked option that is removed",
|
|
input: {
|
|
id: VAR_LIST,
|
|
req: { ...LIST_REQ, options: [{ id: OPTION_A, value: "prod" }] },
|
|
},
|
|
expected: { value: null, defaultValue: OPTION_A },
|
|
},
|
|
{
|
|
name: "leaves a list without options with no default",
|
|
input: { id: VAR_LIST, req: { ...LIST_REQ, options: undefined } },
|
|
expected: { value: null, defaultValue: null },
|
|
},
|
|
] satisfies {
|
|
name: string
|
|
input: { id: string; req: VariableRequest }
|
|
expected: { value: string | null; defaultValue: string | null }
|
|
}[])("$name", async ({ input, expected }, { expect }) => {
|
|
mockTree()
|
|
const put = mockSnapshotEndpoint("PUT", `/api/variables/${input.id}`)
|
|
const api = makeVariableAPI()
|
|
|
|
await api.updateVariable.mutateAsync(input)
|
|
|
|
const variable = findVariable(put.snapshots[0], input.id)
|
|
expect.soft(variable?.value).toBe(expected.value)
|
|
expect.soft(variable?.defaultValue).toBe(expected.defaultValue)
|
|
})
|
|
|
|
it.for([
|
|
{ name: "variable", input: { id: VAR_MISSING, groupId: GROUP_A } },
|
|
{ name: "group", input: { id: VAR_TEXT, groupId: GROUP_MISSING } },
|
|
])(
|
|
"rejects without a request when the $name is not in the tree",
|
|
async ({ input }, { expect }) => {
|
|
const putCalls = mockEndpoint(
|
|
"PUT",
|
|
`/api/variables/${input.id}`,
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariable.mutateAsync({
|
|
id: input.id,
|
|
req: { ...TEXT_REQ, groupId: input.groupId },
|
|
}),
|
|
).rejects.toThrow("invalid variable data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
},
|
|
)
|
|
|
|
it("puts the old variable back when the update fails", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint("PUT", `/api/variables/${VAR_TEXT}`)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariable.mutateAsync({ id: VAR_TEXT, req: TEXT_REQ }),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("deleteVariable", () => {
|
|
it("removes the variable and refreshes the tree", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const del = mockSnapshotEndpoint("DELETE", `/api/variables/${VAR_TEXT}`)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.deleteVariable.mutateAsync(VAR_TEXT),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect.soft(variableIds(del.snapshots[0], GROUP_A)).toEqual([VAR_LIST])
|
|
expect.soft(del.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("puts the variable back when the deletion fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const delCalls = mockFailingEndpoint(
|
|
"DELETE",
|
|
`/api/variables/${VAR_TEXT}`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(api.deleteVariable.mutateAsync(VAR_TEXT)).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(delCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
|
|
it("keeps a tree written after the deletion when it fails", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const del = mockDeferredEndpoint("DELETE", `/api/variables/${VAR_TEXT}`)
|
|
const api = makeVariableAPI()
|
|
|
|
const pending = api.deleteVariable.mutateAsync(VAR_TEXT)
|
|
await del.reached
|
|
|
|
// the deletion shows, then newer data lands before it fails
|
|
expect(variableIds(readTree(), GROUP_A)).toEqual([VAR_LIST])
|
|
seedQueryData(TREE_KEY, [])
|
|
del.reject(createError({ statusCode: 500 }))
|
|
|
|
await expect(pending).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(del.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("setVariableDefault", () => {
|
|
it("moves the chosen option of a list to the top and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_LIST}/default`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableDefault.mutateAsync({
|
|
id: VAR_LIST,
|
|
req: { value: OPTION_B },
|
|
}),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
const variable = findVariable(put.snapshots[0], VAR_LIST)
|
|
expect(variable?.defaultValue).toBe(OPTION_B)
|
|
expect(variable?.options.map((o) => [o.id, o.sortIndex])).toEqual([
|
|
[OPTION_B, 0],
|
|
[OPTION_A, 1],
|
|
])
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual({ value: OPTION_B })
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("sets the default of a text variable", async ({ expect }) => {
|
|
mockTree()
|
|
const put = mockSnapshotEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_TEXT}/default`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await api.setVariableDefault.mutateAsync({
|
|
id: VAR_TEXT,
|
|
req: { value: "ap-south-1" },
|
|
})
|
|
|
|
expect.soft(findVariable(put.snapshots[0], VAR_TEXT)).toEqual({
|
|
...makeTextVariable(),
|
|
defaultValue: "ap-south-1",
|
|
})
|
|
})
|
|
|
|
it.for([
|
|
{ name: "variable", input: { id: VAR_MISSING, value: OPTION_B } },
|
|
{ name: "option", input: { id: VAR_LIST, value: OPTION_MISSING } },
|
|
])(
|
|
"rejects without a request when the $name is not in the tree",
|
|
async ({ input }, { expect }) => {
|
|
const putCalls = mockEndpoint(
|
|
"PUT",
|
|
`/api/variables/${input.id}/default`,
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableDefault.mutateAsync({
|
|
id: input.id,
|
|
req: { value: input.value },
|
|
}),
|
|
).rejects.toThrow("invalid variable default data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
},
|
|
)
|
|
|
|
it("puts the old default back when the request fails", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_LIST}/default`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableDefault.mutateAsync({
|
|
id: VAR_LIST,
|
|
req: { value: OPTION_B },
|
|
}),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("setVariableValue", () => {
|
|
it("shows the caller's value and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_LIST}/value`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableValue.mutateAsync({
|
|
id: VAR_LIST,
|
|
req: { value: OPTION_A },
|
|
}),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect(findVariable(put.snapshots[0], VAR_LIST)?.value).toBe(OPTION_A)
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual({ value: OPTION_A })
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("rejects without a request when the variable is not in the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const putCalls = mockEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_MISSING}/value`,
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableValue.mutateAsync({
|
|
id: VAR_MISSING,
|
|
req: { value: "x" },
|
|
}),
|
|
).rejects.toThrow("invalid variable value data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
})
|
|
|
|
it("puts the old value back when the request fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint(
|
|
"PUT",
|
|
`/api/variables/${VAR_LIST}/value`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.setVariableValue.mutateAsync({
|
|
id: VAR_LIST,
|
|
req: { value: OPTION_A },
|
|
}),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("resetVariableValue", () => {
|
|
it("clears the caller's value and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const del = mockSnapshotEndpoint(
|
|
"DELETE",
|
|
`/api/variables/${VAR_LIST}/value`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.resetVariableValue.mutateAsync(VAR_LIST),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect(findVariable(del.snapshots[0], VAR_LIST)?.value).toBeNull()
|
|
expect.soft(del.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("rejects without a request when the variable is not in the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const delCalls = mockEndpoint(
|
|
"DELETE",
|
|
`/api/variables/${VAR_MISSING}/value`,
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.resetVariableValue.mutateAsync(VAR_MISSING),
|
|
).rejects.toThrow("invalid variable value data")
|
|
|
|
expect.soft(delCalls).toHaveLength(0)
|
|
})
|
|
|
|
it("puts the old value back when the request fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const delCalls = mockFailingEndpoint(
|
|
"DELETE",
|
|
`/api/variables/${VAR_LIST}/value`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.resetVariableValue.mutateAsync(VAR_LIST),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(delCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("createVariableGroup", () => {
|
|
it("shows the new group at the end and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const post = mockSnapshotEndpoint(
|
|
"POST",
|
|
"/api/variables/groups",
|
|
makeGroup(GROUP_B, "Teams"),
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
const created = await api.createVariableGroup.mutateAsync({
|
|
groupName: "Services",
|
|
color: "#f97316",
|
|
})
|
|
await flushPromises()
|
|
|
|
const snapshot = post.snapshots[0]
|
|
expect(snapshot?.map((g) => g.id)).toEqual([
|
|
GROUP_BUILTIN,
|
|
GROUP_A,
|
|
GROUP_B,
|
|
ANY_STRING,
|
|
])
|
|
expect(snapshot?.[3]).toMatchObject({
|
|
organizationId: ORG_ID,
|
|
groupName: "Services",
|
|
color: "#f97316",
|
|
sortIndex: 3,
|
|
builtin: false,
|
|
createdBy: USER_ID,
|
|
variables: [],
|
|
localOptimisticInsert: true,
|
|
})
|
|
expect.soft(created).toEqual(makeGroup(GROUP_B, "Teams"))
|
|
expect.soft(post.calls).toHaveLength(1)
|
|
expect
|
|
.soft(post.calls[0]?.body)
|
|
.toEqual({ groupName: "Services", color: "#f97316" })
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("sends the create without an optimistic insert when session data is missing", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const post = mockSnapshotEndpoint("POST", "/api/variables/groups")
|
|
const api = makeVariableAPI()
|
|
|
|
await api.createVariableGroup.mutateAsync({
|
|
groupName: "Services",
|
|
color: "#f97316",
|
|
})
|
|
await flushPromises()
|
|
|
|
expect.soft(post.snapshots[0]).toEqual(makeTree())
|
|
expect.soft(post.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("drops the new group again when the request fails", async ({
|
|
expect,
|
|
}) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const postCalls = mockFailingEndpoint("POST", "/api/variables/groups")
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.createVariableGroup.mutateAsync({
|
|
groupName: "Services",
|
|
color: "#f97316",
|
|
}),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(postCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("moveVariableGroup", () => {
|
|
it("moves a group, counting only the groups after the built-in one", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint("PUT", "/api/variables/groups/tree")
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariableGroup.mutateAsync({ id: GROUP_B, sortIndex: 0 }),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect(put.snapshots[0]?.map((g) => g.id)).toEqual([
|
|
GROUP_BUILTIN,
|
|
GROUP_B,
|
|
GROUP_A,
|
|
])
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual({ id: GROUP_B, sortIndex: 0 })
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it.for([
|
|
{ name: "a group missing from the tree", input: GROUP_MISSING },
|
|
{ name: "the built-in group", input: GROUP_BUILTIN },
|
|
])("rejects without a request for $name", async ({ input }, { expect }) => {
|
|
const putCalls = mockEndpoint(
|
|
"PUT",
|
|
"/api/variables/groups/tree",
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariableGroup.mutateAsync({ id: input, sortIndex: 1 }),
|
|
).rejects.toThrow("invalid variable group move data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
|
|
it("puts the old order back when the move fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint("PUT", "/api/variables/groups/tree")
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.moveVariableGroup.mutateAsync({ id: GROUP_B, sortIndex: 0 }),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("updateVariableGroup", () => {
|
|
it("applies only the fields sent and refreshes the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const treeCalls = mockTree()
|
|
const put = mockSnapshotEndpoint(
|
|
"PUT",
|
|
`/api/variables/groups/${GROUP_A}`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariableGroup.mutateAsync({
|
|
id: GROUP_A,
|
|
req: { color: "#f97316" },
|
|
}),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
const group = groupOf(put.snapshots[0], GROUP_A)
|
|
expect(group?.color).toBe("#f97316")
|
|
expect(group?.groupName).toBe("Infrastructure")
|
|
expect.soft(put.calls).toHaveLength(1)
|
|
expect.soft(put.calls[0]?.body).toEqual({ color: "#f97316" })
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("rejects without a request when the group is not in the tree", async ({
|
|
expect,
|
|
}) => {
|
|
const putCalls = mockEndpoint(
|
|
"PUT",
|
|
`/api/variables/groups/${GROUP_MISSING}`,
|
|
() => ({}),
|
|
)
|
|
mockTree()
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariableGroup.mutateAsync({
|
|
id: GROUP_MISSING,
|
|
req: { groupName: "Services" },
|
|
}),
|
|
).rejects.toThrow("invalid variable group data")
|
|
|
|
expect.soft(putCalls).toHaveLength(0)
|
|
})
|
|
|
|
it("puts the old group back when the update fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const putCalls = mockFailingEndpoint(
|
|
"PUT",
|
|
`/api/variables/groups/${GROUP_A}`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.updateVariableGroup.mutateAsync({
|
|
id: GROUP_A,
|
|
req: { groupName: "Services" },
|
|
}),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(putCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
describe("deleteVariableGroup", () => {
|
|
it("removes the group and refreshes the tree", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const del = mockSnapshotEndpoint(
|
|
"DELETE",
|
|
`/api/variables/groups/${GROUP_A}`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.deleteVariableGroup.mutateAsync(GROUP_A),
|
|
).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect(del.snapshots[0]?.map((g) => g.id)).toEqual([
|
|
GROUP_BUILTIN,
|
|
GROUP_B,
|
|
])
|
|
expect.soft(del.calls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(1)
|
|
})
|
|
|
|
it("puts the group back when the deletion fails", async ({ expect }) => {
|
|
const treeCalls = mockTree()
|
|
const delCalls = mockFailingEndpoint(
|
|
"DELETE",
|
|
`/api/variables/groups/${GROUP_A}`,
|
|
)
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(
|
|
api.deleteVariableGroup.mutateAsync(GROUP_A),
|
|
).rejects.toThrow()
|
|
await flushPromises()
|
|
|
|
expect.soft(delCalls).toHaveLength(1)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
})
|
|
})
|
|
|
|
// a short id stands for an optimistic insert core has not stored yet,
|
|
// so the mutation skips the cache edit, the request and the refresh
|
|
it.for([
|
|
{
|
|
name: "createVariable",
|
|
method: "POST",
|
|
url: VARIABLES_URL,
|
|
invoke: (api: VariableAPI) =>
|
|
api.createVariable.mutateAsync({ ...LIST_REQ, groupId: SHORT_ID }),
|
|
},
|
|
{
|
|
name: "moveVariable",
|
|
method: "PUT",
|
|
url: TREE_URL,
|
|
invoke: (api: VariableAPI) =>
|
|
api.moveVariable.mutateAsync({
|
|
id: SHORT_ID,
|
|
groupId: GROUP_A,
|
|
sortIndex: 0,
|
|
}),
|
|
},
|
|
{
|
|
name: "updateVariable",
|
|
method: "PUT",
|
|
url: `/api/variables/${SHORT_ID}`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.updateVariable.mutateAsync({ id: SHORT_ID, req: TEXT_REQ }),
|
|
},
|
|
{
|
|
name: "deleteVariable",
|
|
method: "DELETE",
|
|
url: `/api/variables/${SHORT_ID}`,
|
|
invoke: (api: VariableAPI) => api.deleteVariable.mutateAsync(SHORT_ID),
|
|
},
|
|
{
|
|
name: "setVariableDefault",
|
|
method: "PUT",
|
|
url: `/api/variables/${SHORT_ID}/default`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.setVariableDefault.mutateAsync({
|
|
id: SHORT_ID,
|
|
req: { value: "x" },
|
|
}),
|
|
},
|
|
{
|
|
name: "setVariableValue",
|
|
method: "PUT",
|
|
url: `/api/variables/${SHORT_ID}/value`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.setVariableValue.mutateAsync({
|
|
id: SHORT_ID,
|
|
req: { value: "x" },
|
|
}),
|
|
},
|
|
{
|
|
name: "resetVariableValue",
|
|
method: "DELETE",
|
|
url: `/api/variables/${SHORT_ID}/value`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.resetVariableValue.mutateAsync(SHORT_ID),
|
|
},
|
|
{
|
|
name: "moveVariableGroup",
|
|
method: "PUT",
|
|
url: "/api/variables/groups/tree",
|
|
invoke: (api: VariableAPI) =>
|
|
api.moveVariableGroup.mutateAsync({ id: SHORT_ID, sortIndex: 0 }),
|
|
},
|
|
{
|
|
name: "updateVariableGroup",
|
|
method: "PUT",
|
|
url: `/api/variables/groups/${SHORT_ID}`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.updateVariableGroup.mutateAsync({
|
|
id: SHORT_ID,
|
|
req: { color: "#f97316" },
|
|
}),
|
|
},
|
|
{
|
|
name: "deleteVariableGroup",
|
|
method: "DELETE",
|
|
url: `/api/variables/groups/${SHORT_ID}`,
|
|
invoke: (api: VariableAPI) =>
|
|
api.deleteVariableGroup.mutateAsync(SHORT_ID),
|
|
},
|
|
] as const)(
|
|
"$name bails out for an optimistic insert id",
|
|
async ({ method, url, invoke }, { expect }) => {
|
|
seedAuth()
|
|
const treeCalls = mockTree()
|
|
const calls = mockEndpoint(method, url, () => ({}))
|
|
const api = makeVariableAPI()
|
|
|
|
await expect(invoke(api)).resolves.toBeUndefined()
|
|
await flushPromises()
|
|
|
|
expect.soft(calls).toHaveLength(0)
|
|
expect.soft(treeCalls).toHaveLength(0)
|
|
expect.soft(readTree()).toEqual(makeTree())
|
|
},
|
|
)
|
|
})
|
|
|
|
function makeVariableAPI(): VariableAPI {
|
|
return runInApp(() => useVariableAPI())
|
|
}
|
|
|
|
function seedAuth() {
|
|
seedAuthSession(USER_ID)
|
|
seedAuthOrganization(ORG_ID)
|
|
}
|
|
|
|
// seeds the cached tree and answers its refetch with an empty one, so a
|
|
// test can count the refreshes and see that one landed
|
|
function mockTree() {
|
|
seedQueryData(TREE_KEY, makeTree())
|
|
|
|
return mockEndpoint("GET", TREE_URL, () => [])
|
|
}
|
|
|
|
// records the cached tree as each request arrives, which is the optimistic
|
|
// state the mutation wrote
|
|
function mockSnapshotEndpoint(
|
|
method: "POST" | "PUT" | "DELETE",
|
|
url: string,
|
|
response: unknown = {},
|
|
) {
|
|
const snapshots: (VariableTreeResponse | undefined)[] = []
|
|
const calls = mockEndpoint(method, url, () => {
|
|
snapshots.push(readTree())
|
|
|
|
return response
|
|
})
|
|
|
|
return { calls, snapshots }
|
|
}
|
|
|
|
function mockFailingEndpoint(method: "POST" | "PUT" | "DELETE", url: string) {
|
|
return mockEndpoint(method, url, () => {
|
|
throw createError({ statusCode: 500 })
|
|
})
|
|
}
|
|
|
|
function readTree() {
|
|
return readQueryData(TREE_KEY) as VariableTreeResponse | undefined
|
|
}
|
|
|
|
function groupOf(tree: VariableTreeResponse | undefined, id: string) {
|
|
return tree?.find((g) => g.id === id)
|
|
}
|
|
|
|
function variableIds(tree: VariableTreeResponse | undefined, groupId: string) {
|
|
return groupOf(tree, groupId)?.variables.map((v) => v.id)
|
|
}
|
|
|
|
function findVariable(tree: VariableTreeResponse | undefined, id: string) {
|
|
return tree?.flatMap((g) => g.variables).find((v) => v.id === id)
|
|
}
|
|
|
|
function makeTree(): VariableTreeResponse {
|
|
return [
|
|
makeGroup(GROUP_BUILTIN, "Built-in", [], true),
|
|
makeGroup(GROUP_A, "Infrastructure", [
|
|
makeListVariable(),
|
|
makeTextVariable(),
|
|
]),
|
|
makeGroup(GROUP_B, "Teams"),
|
|
]
|
|
}
|
|
|
|
function makeGroup(
|
|
id: string,
|
|
groupName: string,
|
|
variables: VariableTreeElement[] = [],
|
|
builtin = false,
|
|
): VariableGroupTreeElement {
|
|
return {
|
|
id,
|
|
organizationId: ORG_ID,
|
|
groupName,
|
|
color: "#22c55e",
|
|
sortIndex: 0,
|
|
builtin,
|
|
createdAt: "2026-10-13T10:00:00Z",
|
|
createdBy: builtin ? null : USER_ID,
|
|
variables,
|
|
}
|
|
}
|
|
|
|
function makeListVariable(): VariableTreeElement {
|
|
return {
|
|
id: VAR_LIST,
|
|
organizationId: ORG_ID,
|
|
groupId: GROUP_A,
|
|
key: "env",
|
|
name: "Environment",
|
|
description: null,
|
|
type: "list",
|
|
defaultValue: OPTION_A,
|
|
sortIndex: 0,
|
|
builtin: false,
|
|
createdAt: "2026-10-13T10:00:00Z",
|
|
createdBy: USER_ID,
|
|
value: OPTION_B,
|
|
options: [
|
|
{ id: OPTION_A, variableId: VAR_LIST, value: "production", sortIndex: 0 },
|
|
{ id: OPTION_B, variableId: VAR_LIST, value: "staging", sortIndex: 1 },
|
|
],
|
|
}
|
|
}
|
|
|
|
function makeTextVariable(): VariableTreeElement {
|
|
return {
|
|
id: VAR_TEXT,
|
|
organizationId: ORG_ID,
|
|
groupId: GROUP_A,
|
|
key: "region",
|
|
name: "Region",
|
|
description: null,
|
|
type: "text",
|
|
defaultValue: "eu-west-1",
|
|
sortIndex: 1,
|
|
builtin: false,
|
|
createdAt: "2026-10-13T10:00:00Z",
|
|
createdBy: USER_ID,
|
|
value: "us-east-1",
|
|
options: [],
|
|
}
|
|
}
|