Files
template-app/modules/catalog/__tests__/procedures.test.ts
T
2026-11-14 09:56:45 +00:00

225 lines
9.5 KiB
TypeScript

// The catalog procedures end to end: the caller, the tenancy resolver, the module and the real generated
// client against the business-services fake -- what a member sees, what the service gets, and each
// refusal as the kit's app error (`businessAppError`) a page or a form shows.
import type { AppError } from "@conusai-kit/core"
import { entitiesOf } from "@conusai-kit/data"
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
import { sdkCatalogService } from "../adapters/service.js"
import { catalogApp } from "./app.js"
import { catalogIsolationFixtures } from "./isolation.js"
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
try {
await promise
} catch (error) {
return error as AppError
}
throw new Error("the call was not refused")
}
const HASH = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
const bolt = { code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62" }
const nut = { code: "NUT-M8", name: "Nut M8", baseUnit: "C62", description: "A nut" }
const importOf = (rows: unknown[], over: Record<string, unknown> = {}) => ({
fileHash: HASH,
sourceName: "products.csv",
format: "csv",
chunk: 0,
finalChunk: true,
rows,
requestId: crypto.randomUUID(),
...over,
})
async function seeded(options: Parameters<typeof catalogApp>[0] = {}) {
const app = catalogApp(options)
const t = await app.tenant("alpha")
await t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf([
{ sourceRow: 2, product: bolt },
{ sourceRow: 3, product: nut },
]),
})
return { app, t }
}
describe("the catalog procedures", () => {
it("apply an import, and read its products, with every int64 a plain value", async () => {
const app = catalogApp()
const t = await app.tenant("alpha")
const requestId = crypto.randomUUID()
const applied = await t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf([{ sourceRow: 2, product: bolt }], { requestId }),
})
expect(applied).toMatchObject({
import: { sourceName: "products.csv", status: "applied", revision: "1", created: 1, refused: 0 },
rows: [{ sourceRow: 2, outcome: "created", reason: null }],
})
expect(JSON.parse(JSON.stringify(applied))).toEqual(applied)
// A retried request applies once.
expect(
await t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf([{ sourceRow: 2, product: bolt }], { requestId }),
}),
).toEqual(applied)
expect(app.fake.catalog.products.size).toBe(1)
const listed = (await t.call.callByName("catalog.product.list", t.slugs)) as {
rows: Array<{ productId: string }>
}
expect(listed.rows).toEqual([
expect.objectContaining({ code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62", status: "active", revision: "1" }),
])
const productId = listed.rows[0]?.productId as string
expect(await t.call.callByName("catalog.product.get", { ...t.slugs, productId })).toEqual(listed.rows[0])
expect(await t.call.callByName("catalog.import.list", t.slugs)).toMatchObject({
rows: [{ sourceName: "products.csv", created: 1 }],
})
})
it("search by name, by code and by identifier", async () => {
const { t } = await seeded()
const search = async (q: string, by?: string) =>
(
(await t.call.callByName("catalog.product.search", { ...t.slugs, q, ...(by ? { by } : {}) })) as {
rows: Array<{ code: string }>
}
).rows.map((row) => row.code)
expect(await search(" nut ")).toEqual(["NUT-M8"])
expect(await search("BOLT", "code")).toEqual(["BOLT-M8"])
expect(await search("nothing")).toEqual([])
expect(await search("4006381333931", "gtin")).toEqual([])
})
it("carry a chunk's revision forward, and report a refused row without losing the others", async () => {
const app = catalogApp()
const t = await app.tenant("alpha")
const first = (await t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf([{ sourceRow: 2, product: bolt }], { finalChunk: false }),
})) as { import: { revision: string; status: string } }
expect(first.import.status).toBe("applying")
const second = await t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf(
[
{ sourceRow: 3, product: nut },
{ sourceRow: 4, product: { code: "BAD CODE", name: "Bad", baseUnit: "C62" } },
],
{ chunk: 1, revision: first.import.revision },
),
})
expect(second).toMatchObject({
import: { status: "applying", revision: "2", created: 2, refused: 1 },
rows: [
{ sourceRow: 3, outcome: "created" },
{ sourceRow: 4, outcome: "refused", productId: null, reason: "invalid_fields" },
],
})
})
it("show the service's refusals in the service's words", async () => {
const app = catalogApp()
const t = await app.tenant("alpha")
const missing = await refusal(t.call.callByName("catalog.product.get", { ...t.slugs, productId: "nobody" }))
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
const stale = await refusal(
t.call.callByName("catalog.import.apply", {
...t.slugs,
...importOf([{ sourceRow: 2, product: bolt }], { chunk: 1 }),
}),
)
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
})
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
const app = catalogApp({ catalog: false })
const t = await app.tenant("alpha")
const disabled = await refusal(t.call.callByName("catalog.product.list", t.slugs))
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
app.fake.enable("catalog")
app.fake.revoke("catalog.imports:write")
const scoped = await refusal(
t.call.callByName("catalog.import.apply", { ...t.slugs, ...importOf([{ sourceRow: 2, product: bolt }]) }),
)
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
expect(scoped.problem.detail).not.toContain("catalog.imports:write")
app.fake.refuseNext(503)
const silent = await refusal(t.call.callByName("catalog.product.list", t.slugs))
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
})
it("let a viewer read and refuse their import before the service is called", async () => {
const { app, t } = await seeded()
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
const listed = (await viewer.callByName("catalog.product.list", t.slugs)) as { rows: unknown[] }
expect(listed.rows).toHaveLength(2)
const before = app.fake.calls.length
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
const refused = await refusal(
viewer.callByName("catalog.import.apply", { ...t.slugs, ...importOf([{ sourceRow: 9, product: bolt }]) }),
)
expect(refused.problem.status).toBe(404)
expect(app.fake.calls.length).toBe(before)
})
it("need no scope beyond the ones `kit module:on catalog` asks for", async () => {
const { t } = await seeded({ scopes: ["catalog.products:read", "catalog.imports:read", "catalog.imports:write"] })
const { rows } = (await t.call.callByName("catalog.product.list", t.slugs)) as {
rows: Array<{ productId: string }>
}
expect(rows).toHaveLength(2)
await t.call.callByName("catalog.product.search", { ...t.slugs, q: "nut" })
await t.call.callByName("catalog.product.get", { ...t.slugs, productId: rows[0]?.productId })
await t.call.callByName("catalog.import.list", t.slugs)
})
it("refuses a record of another platform organization: the token reaches only its own", async () => {
const { app } = await seeded()
const [product] = [...app.fake.catalog.products.values()]
const elsewhere = sdkCatalogService({ client: app.client, orgSlug: "another-organization" })
await expect(elsewhere.getProduct(product?.id as string)).rejects.toMatchObject({ problem: { status: 404 } })
})
})
describe("the catalog module's tenant isolation", () => {
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
const app = catalogApp()
const tenant = async (slug: string): Promise<IsolationTenant> => {
const t = await app.tenant(slug)
return {
orgSlug: t.orgSlug,
workspaceSlug: t.workspaceSlug,
projectSlug: t.projectSlug,
orgId: t.orgId,
workspaceId: t.workspaceId,
projectId: t.projectId,
userId: t.owner.userId,
email: `owner@${slug}.test`,
extra: {},
}
}
const a = await tenant("alpha")
const b = await tenant("bravo")
const result = await runIsolation({
registry: app.registry,
seed: async () => ({ a, b }),
caller: (actor) => app.caller(actor),
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
fixtures: { ...tenancyIsolationFixtures(), ...catalogIsolationFixtures },
alsoCheck: [...TENANCY_ALSO_CHECK],
exempt: tenancyIsolationExempt,
})
expect(result.failures).toEqual([])
for (const name of Object.keys(catalogIsolationFixtures)) expect(result.passed, name).toContain(name)
expect(app.fake.calls).toEqual([])
})
})