@@ -0,0 +1,240 @@
|
||||
// The inventory 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 { businessClient } from "@conusai-kit/business"
|
||||
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 { inventoryCreateLocation, inventoryRecordMovement } from "@conusai-sdk/inventory"
|
||||
import { createClient } from "@conusai-sdk/inventory/client"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { projectReference } from "../core/inventory.js"
|
||||
import { BINDING_ID, inventoryApp } from "./app.js"
|
||||
import { inventoryIsolationFixtures } 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 bolt = { sku: "BOLT-8", name: "Bolt M8", unit: "pcs" }
|
||||
|
||||
/** Stock as the service receives it: a new location, then a receipt into it; answers the location's id. */
|
||||
const receive = async (app: ReturnType<typeof inventoryApp>, itemId: string, quantity: number): Promise<string> => {
|
||||
const client = businessClient(
|
||||
{ apiUrl: app.fake.apiUrl, token: app.fake.token, orgSlug: app.fake.orgSlug },
|
||||
createClient,
|
||||
)
|
||||
client.setConfig({ fetch: app.fake.fetch as typeof fetch })
|
||||
const path = { org_slug: app.fake.orgSlug }
|
||||
const code = `STORE-${app.fake.inventory.locations.size + 1}`
|
||||
const location = await inventoryCreateLocation({ client, path, body: { code, name: "Store" } })
|
||||
const locationId = location.data?.id as string
|
||||
const receipt = await inventoryRecordMovement({
|
||||
client,
|
||||
path,
|
||||
body: {
|
||||
kind: "receipt",
|
||||
item_id: itemId,
|
||||
quantity: BigInt(quantity),
|
||||
to_location_id: locationId,
|
||||
idempotency_key: `receipt:${locationId}`,
|
||||
},
|
||||
})
|
||||
expect(receipt.response?.status).toBe(201)
|
||||
return locationId
|
||||
}
|
||||
|
||||
describe("the inventory procedures", () => {
|
||||
it("create an item, list it, and read its stock", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const created = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
|
||||
expect(created).toMatchObject({ sku: "BOLT-8", name: "Bolt M8", unit: "pcs", active: true, description: null })
|
||||
const listed = (await t.call.callByName("inventory.item.list", t.slugs)) as {
|
||||
rows: { itemId: string }[]
|
||||
after?: string
|
||||
}
|
||||
expect(listed.rows.map((row) => row.itemId)).toEqual([created.itemId])
|
||||
expect(listed.after).toBeUndefined()
|
||||
await receive(app, created.itemId, 12)
|
||||
const stock = (await t.call.callByName("inventory.item.availability", { ...t.slugs, itemId: created.itemId })) as {
|
||||
availability: { onHand: number; available: number; locations: unknown[] }
|
||||
}
|
||||
expect(stock.availability).toMatchObject({ onHand: 12, available: 12 })
|
||||
expect(stock.availability.locations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("reserve against the project's reference, list the project's reservations, and release them", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
|
||||
const locationId = await receive(app, itemId, 10)
|
||||
const requestId = crypto.randomUUID()
|
||||
const reserve = { ...t.slugs, itemId, locationId, quantity: 3, requestId }
|
||||
const reserved = (await t.call.callByName("inventory.stock.reserve", reserve)) as { reservationId: string }
|
||||
expect(reserved).toMatchObject({ quantity: 3, outstanding: 3, status: "open" })
|
||||
// A retried request reserves once.
|
||||
expect(await t.call.callByName("inventory.stock.reserve", reserve)).toEqual(reserved)
|
||||
expect(app.fake.inventory.reservations.size).toBe(1)
|
||||
const held = [...app.fake.inventory.reservations.values()][0]
|
||||
expect(held?.reference).toBe(projectReference(BINDING_ID, t.projectId))
|
||||
expect(held?.reference).toBe(`conusai://app-partitions/${BINDING_ID}/projects/${t.projectId}`)
|
||||
|
||||
const listed = (await t.call.callByName("inventory.reservation.list", { ...t.slugs, status: "open" })) as {
|
||||
rows: unknown[]
|
||||
}
|
||||
expect(listed.rows).toEqual([reserved])
|
||||
const released = await t.call.callByName("inventory.stock.release", {
|
||||
...t.slugs,
|
||||
reservationId: reserved.reservationId,
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(released).toMatchObject({ outstanding: 0, released: 3, status: "closed" })
|
||||
})
|
||||
|
||||
it("keep one project's reservations from another project of the same organization", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const other = (await t.call.callByName("tenancy.project.create", {
|
||||
org: t.orgSlug,
|
||||
workspace: t.workspaceSlug,
|
||||
slug: "other",
|
||||
name: "Other",
|
||||
moduleKey: "inventory",
|
||||
})) as { slug: string }
|
||||
const otherSlugs = { ...t.slugs, project: other.slug }
|
||||
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
|
||||
const locationId = await receive(app, itemId, 10)
|
||||
const mine = (await t.call.callByName("inventory.stock.reserve", {
|
||||
...t.slugs,
|
||||
itemId,
|
||||
locationId,
|
||||
quantity: 1,
|
||||
requestId: crypto.randomUUID(),
|
||||
})) as { reservationId: string }
|
||||
expect(((await t.call.callByName("inventory.reservation.list", otherSlugs)) as { rows: unknown[] }).rows).toEqual(
|
||||
[],
|
||||
)
|
||||
const refused = await refusal(
|
||||
t.call.callByName("inventory.stock.release", {
|
||||
...otherSlugs,
|
||||
reservationId: mine.reservationId,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(refused.problem).toMatchObject({ status: 404, code: "not_found" })
|
||||
expect(app.fake.inventory.reservations.get(mine.reservationId)?.outstanding).toBe(1)
|
||||
})
|
||||
|
||||
it("answer the service's refusals with the kit's codes, carrying none of the service's detail", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })
|
||||
const taken = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt }))
|
||||
expect(taken.problem).toMatchObject({ status: 409, code: "business_duplicate", title: "This already exists." })
|
||||
expect(taken.problem.detail).toBeUndefined()
|
||||
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt, sku: "NUT-8" })) as {
|
||||
itemId: string
|
||||
}
|
||||
const locationId = await receive(app, itemId, 1)
|
||||
const short = await refusal(
|
||||
t.call.callByName("inventory.stock.reserve", {
|
||||
...t.slugs,
|
||||
itemId,
|
||||
locationId,
|
||||
quantity: 2,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(short.problem).toMatchObject({ status: 409, code: "business_insufficient_stock" })
|
||||
})
|
||||
|
||||
it("answer a disabled service, a missing scope and a silent service with the kit's codes", async () => {
|
||||
const app = inventoryApp({ inventory: false })
|
||||
const t = await app.tenant("alpha")
|
||||
const disabled = await refusal(t.call.callByName("inventory.item.list", t.slugs))
|
||||
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
||||
|
||||
app.fake.enable("inventory")
|
||||
app.fake.revoke("inventory.items:write")
|
||||
const scoped = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt }))
|
||||
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
||||
expect(scoped.problem.detail).not.toContain("inventory.items:write")
|
||||
|
||||
app.fake.refuseNext("network")
|
||||
const silent = await refusal(t.call.callByName("inventory.item.list", t.slugs))
|
||||
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
||||
expect(JSON.stringify([disabled.problem, scoped.problem, silent.problem])).not.toContain(app.fake.token)
|
||||
})
|
||||
|
||||
it("let a viewer read and refuse their write before the service is called", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
||||
expect(await viewer.callByName("inventory.item.list", t.slugs)).toEqual({ rows: [] })
|
||||
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("inventory.item.create", { ...t.slugs, ...bolt }))
|
||||
expect(refused.problem.status).toBe(404)
|
||||
const reserve = await refusal(
|
||||
viewer.callByName("inventory.stock.reserve", {
|
||||
...t.slugs,
|
||||
itemId: crypto.randomUUID(),
|
||||
locationId: crypto.randomUUID(),
|
||||
quantity: 1,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(reserve.problem.status).toBe(404)
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it("refuse a body outside the contract before the service is called", async () => {
|
||||
const app = inventoryApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const refused = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt, unit: "Pieces" }))
|
||||
expect(refused.problem.status).toBe(422)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the inventory module's tenant isolation", () => {
|
||||
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
||||
const app = inventoryApp()
|
||||
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(), ...inventoryIsolationFixtures },
|
||||
alsoCheck: [...TENANCY_ALSO_CHECK],
|
||||
exempt: tenancyIsolationExempt,
|
||||
})
|
||||
expect(result.failures).toEqual([])
|
||||
for (const name of Object.keys(inventoryIsolationFixtures)) expect(result.passed, name).toContain(name)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user