@@ -0,0 +1,224 @@
|
||||
// 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user