@@ -0,0 +1,106 @@
|
||||
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
|
||||
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
|
||||
// source, the inventory module, and the Inventory port bound to the real generated client over the
|
||||
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
|
||||
// app turned the module on.
|
||||
|
||||
import { businessClient } from "@conusai-kit/business"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { type FakeBusinessServices, fakeBusinessServices } from "@conusai-kit/testing"
|
||||
import { createClient } from "@conusai-sdk/inventory/client"
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
import { sdkInventoryService } from "../adapters/service.js"
|
||||
import { INVENTORY_SERVICE } from "../core/ports.js"
|
||||
import { inventoryModule } from "../module.js"
|
||||
|
||||
/** The platform project of the fake deployment. */
|
||||
export const BINDING_ID = "01900000-0000-7000-8000-000000000001"
|
||||
|
||||
export interface Tenant {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceSlug: string
|
||||
readonly workspaceId: string
|
||||
readonly projectSlug: string
|
||||
readonly projectId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
export function inventoryApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
|
||||
const fake: FakeBusinessServices = fakeBusinessServices(options)
|
||||
const client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
|
||||
client.setConfig({ fetch: fake.fetch as typeof fetch })
|
||||
const registry = buildRegistry([tenancyModule, inventoryModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(INVENTORY_SERVICE, sdkInventoryService({ client, orgSlug: fake.orgSlug, bindingId: BINDING_ID }))
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** An organization with one workspace and one project, owned by a new user. */
|
||||
const tenant = async (slug: string): Promise<Tenant> => {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "inventory",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceSlug: workspace.slug,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: project.slug,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A second user who joins `t`'s organization with the org role `role`. */
|
||||
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
|
||||
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
|
||||
token: string
|
||||
}
|
||||
const actor = await signUp(email)
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
|
||||
return call
|
||||
}
|
||||
|
||||
return { fake, registry, data, caller, signUp, tenant, member }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// The inventory use cases over a recording port: what each asks the service, with no client and no
|
||||
// network.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ReservationView } from "../contract/index.js"
|
||||
import {
|
||||
createItem,
|
||||
listItems,
|
||||
listProjectReservations,
|
||||
projectReference,
|
||||
releaseStock,
|
||||
requestKey,
|
||||
reserveStock,
|
||||
} from "../core/inventory.js"
|
||||
import { type InventoryService, inventoryReadsOf } from "../core/ports.js"
|
||||
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
actor: { userId: "u1" },
|
||||
orgId: "o1",
|
||||
workspaceId: "w1",
|
||||
projectId: "01900000-0000-7000-8000-000000000003",
|
||||
}
|
||||
|
||||
const reservation: ReservationView = {
|
||||
reservationId: "r1",
|
||||
itemId: "i1",
|
||||
locationId: "l1",
|
||||
quantity: 2,
|
||||
outstanding: 2,
|
||||
released: 0,
|
||||
issued: 0,
|
||||
status: "open",
|
||||
createdAt: "2026-11-02T00:00:00.000Z",
|
||||
}
|
||||
|
||||
function recording(
|
||||
reference = projectReference("01900000-0000-7000-8000-000000000001", "01900000-0000-7000-8000-000000000003"),
|
||||
) {
|
||||
const asked: Array<[string, unknown]> = []
|
||||
const service: InventoryService = {
|
||||
bindingId: "01900000-0000-7000-8000-000000000001",
|
||||
listItems: async (query) => {
|
||||
asked.push(["listItems", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getItem: async () => {
|
||||
throw new Error("not used")
|
||||
},
|
||||
availability: async () => {
|
||||
throw new Error("not used")
|
||||
},
|
||||
listReservations: async (query) => {
|
||||
asked.push(["listReservations", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getReservation: async (id) => {
|
||||
asked.push(["getReservation", id])
|
||||
return { reservation, reference }
|
||||
},
|
||||
createItem: async (input) => {
|
||||
asked.push(["createItem", input])
|
||||
return { itemId: "i1", description: null, active: true, ...input }
|
||||
},
|
||||
reserve: async (input) => {
|
||||
asked.push(["reserve", input])
|
||||
return reservation
|
||||
},
|
||||
release: async (input) => {
|
||||
asked.push(["release", input])
|
||||
return { ...reservation, outstanding: 0, released: 2, status: "closed" }
|
||||
},
|
||||
}
|
||||
return { service, asked }
|
||||
}
|
||||
|
||||
describe("the project reference", () => {
|
||||
it("names the immutable app binding and resolved project, in the service's reference grammar", () => {
|
||||
const reference = projectReference("01900000-0000-7000-8000-000000000001", "0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a")
|
||||
expect(reference).toBe(
|
||||
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a",
|
||||
)
|
||||
expect(reference).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/)
|
||||
})
|
||||
|
||||
it("keys a request by the project, so one project's request id never answers another's", () => {
|
||||
expect(requestKey({ projectId: "01900000-0000-7000-8000-000000000003" }, "r")).not.toBe(
|
||||
requestKey({ projectId: "01900000-0000-7000-8000-000000000004" }, "r"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the inventory use cases", () => {
|
||||
it("list items with a trimmed search and the module's page size", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listItems(service, { q: " bolt " })
|
||||
await listItems(service, { q: " ", after: "x", limit: 5 })
|
||||
expect(asked).toEqual([
|
||||
["listItems", { q: "bolt", limit: 50 }],
|
||||
["listItems", { after: "x", limit: 5 }],
|
||||
])
|
||||
})
|
||||
|
||||
it("create an item without an empty description", async () => {
|
||||
const { service, asked } = recording()
|
||||
await createItem(service, { sku: "B", name: " Bolt ", unit: "pcs", description: " " })
|
||||
expect(asked).toEqual([["createItem", { sku: "B", name: "Bolt", unit: "pcs" }]])
|
||||
})
|
||||
|
||||
it("list and reserve against the project's reference with the project's request key", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listProjectReservations(service, scope, { status: "open" })
|
||||
await reserveStock(service, scope, { itemId: "i1", locationId: "l1", quantity: 2, requestId: "q1" })
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"listReservations",
|
||||
{
|
||||
reference:
|
||||
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/01900000-0000-7000-8000-000000000003",
|
||||
status: "open",
|
||||
limit: 50,
|
||||
},
|
||||
],
|
||||
[
|
||||
"reserve",
|
||||
{
|
||||
itemId: "i1",
|
||||
locationId: "l1",
|
||||
quantity: 2,
|
||||
reference:
|
||||
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/01900000-0000-7000-8000-000000000003",
|
||||
key: requestKey(scope, "q1"),
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("release a reservation of the project, and answer another project's as not found without releasing it", async () => {
|
||||
const mine = recording()
|
||||
expect(await releaseStock(mine.service, scope, { reservationId: "r1", requestId: "q2" })).toMatchObject({
|
||||
status: "closed",
|
||||
})
|
||||
expect(mine.asked.at(-1)).toEqual(["release", { reservationId: "r1", key: requestKey(scope, "q2") }])
|
||||
|
||||
const theirs = recording(
|
||||
projectReference("01900000-0000-7000-8000-000000000001", "01900000-0000-7000-8000-000000000004"),
|
||||
)
|
||||
await expect(releaseStock(theirs.service, scope, { reservationId: "r1", requestId: "q3" })).rejects.toMatchObject({
|
||||
problem: { status: 404 },
|
||||
})
|
||||
expect(theirs.asked.map(([method]) => method)).toEqual(["getReservation"])
|
||||
})
|
||||
|
||||
it("hand a read handler a view without a single write", () => {
|
||||
const view = inventoryReadsOf(recording().service) as unknown as Record<string, unknown>
|
||||
expect(Object.keys(view).sort()).toEqual([
|
||||
"availability",
|
||||
"bindingId",
|
||||
"getItem",
|
||||
"getReservation",
|
||||
"listItems",
|
||||
"listReservations",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian, the Lithuanian
|
||||
// plural forms are real ones, and no component of the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { inventoryModule } from "../module.js"
|
||||
import { itemFormLabels, itemListLabels, reservationListLabels, reserveFormLabels, stockLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
|
||||
const catalogues = buildRegistry([tenancyModule, inventoryModule]).catalogues
|
||||
const en = catalogues.en?.inventory ?? {}
|
||||
const lt = catalogues.lt?.inventory ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
itemFormLabels(t, {})
|
||||
itemListLabels(t)
|
||||
stockLabels(t, "pcs", 2)
|
||||
reserveFormLabels(t, {})
|
||||
reservationListLabels(t, {})
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the inventory catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = keysUsed()
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing inventory.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing inventory.${key}`).toBeTruthy()
|
||||
}
|
||||
// The page and the navigation ask for these directly.
|
||||
const direct = new Set(["title", "intro", "items.title", "item.back"])
|
||||
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
expect(inventoryModule.ui?.nav?.map((item) => item.label)).toEqual(["inventory.title"])
|
||||
})
|
||||
|
||||
it("count locations with the Lithuanian one, few and other forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { inventory: en }, lt: { inventory: lt } }).message("inventory.item.locations", {
|
||||
count: n,
|
||||
})
|
||||
expect(count("lt", 1)).toBe("1 vieta su atsargomis")
|
||||
expect(count("lt", 3)).toBe("3 vietos su atsargomis")
|
||||
expect(count("lt", 10)).toBe("10 vietų su atsargomis")
|
||||
expect(count("lt", 21)).toBe("21 vieta su atsargomis")
|
||||
expect(count("en", 1)).toBe("1 location with stock")
|
||||
expect(count("en", 2)).toBe("2 locations with stock")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(5)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
|
||||
// another's slugs, which the resolver must refuse before the Inventory service is reached.
|
||||
//
|
||||
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
|
||||
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
|
||||
// its place whether or not the module is on, and checks only the procedures the app registers, so
|
||||
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
|
||||
// same fixtures against the module on its own, on or off.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
export const inventoryIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"inventory.item.list": { input: (t) => inProjectOf(t) },
|
||||
"inventory.item.create": { input: (t) => ({ ...inProjectOf(t), sku: "INTRUDER-1", name: "Intruder", unit: "pcs" }) },
|
||||
"inventory.item.availability": { input: (t) => ({ ...inProjectOf(t), itemId: SOMEWHERE }) },
|
||||
"inventory.reservation.list": { input: (t) => inProjectOf(t) },
|
||||
"inventory.stock.reserve": {
|
||||
input: (t) => ({ ...inProjectOf(t), itemId: SOMEWHERE, locationId: SOMEWHERE, quantity: 1, requestId: SOMEWHERE }),
|
||||
},
|
||||
"inventory.stock.release": { input: (t) => ({ ...inProjectOf(t), reservationId: SOMEWHERE, requestId: SOMEWHERE }) },
|
||||
}
|
||||
|
||||
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = {
|
||||
seed: async () => ({}),
|
||||
fixtures: businessModulesOn.includes("inventory") ? inventoryIsolationFixtures : {},
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
// Prepared request binding is required; injected test ports preserve kind capabilities.
|
||||
|
||||
import { AppError, Container } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { inventoryServiceOf } from "../adapters/service.js"
|
||||
import { INVENTORY_SERVICE, type InventoryService } from "../core/ports.js"
|
||||
|
||||
const service = {
|
||||
bindingId: "01900000-0000-7000-8000-000000000001",
|
||||
createItem: async () => {
|
||||
throw new Error("not called")
|
||||
},
|
||||
} as unknown as InventoryService
|
||||
|
||||
describe("inventoryServiceOf", () => {
|
||||
it("hands a read handler the bound service's reads only, and an effect handler all of it", () => {
|
||||
const root = new Container()
|
||||
root.value(INVENTORY_SERVICE, service)
|
||||
const read = inventoryServiceOf(root.restrict("read"), "read") as unknown as Record<string, unknown>
|
||||
expect(read.createItem).toBeUndefined()
|
||||
expect(read.bindingId).toBe("01900000-0000-7000-8000-000000000001")
|
||||
expect(inventoryServiceOf(root.restrict("effect"), "effect").createItem).toBe(service.createItem)
|
||||
})
|
||||
|
||||
it("refuses a request without a prepared partition binding", () => {
|
||||
let thrown: unknown
|
||||
try {
|
||||
inventoryServiceOf(new Container(), "read")
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AppError)
|
||||
expect((thrown as AppError).problem).toMatchObject({ status: 409, code: "service_partition_missing" })
|
||||
expect((thrown as AppError).problem.detail).not.toContain("token")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
// The pages and components, rendered on the server the way the shell renders them: the platform's
|
||||
// refusals are the page's own states, every control has a name, every table a caption and scoped
|
||||
// headers.
|
||||
|
||||
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactNode } from "react"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import { inventoryModule } from "../module.js"
|
||||
import { inventoryPage } from "../ui/pages.js"
|
||||
|
||||
const catalogues = buildRegistry([tenancyModule, inventoryModule]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { inventory: catalogues.en?.inventory ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
const ITEM = "0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a"
|
||||
const LOCATION = "7d1e2f3a-0000-4000-8000-000000000001"
|
||||
|
||||
const EVERYTHING: readonly PermissionKey[] = ["inventory.item.read", "inventory.item.write", "inventory.stock.reserve"]
|
||||
|
||||
/** Rendered as the shell renders a page: a data client, and the caller's permissions in the page's scope. */
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = EVERYTHING) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
|
||||
{node}
|
||||
</PermissionsProvider>
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
/** A caller that answers each procedure from `answers`, or throws what is there. */
|
||||
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
const load = (segments: string[], answers: Record<string, unknown>, reader = i18n) =>
|
||||
inventoryPage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: callerOf(answers),
|
||||
i18n: reader,
|
||||
container: new Container(),
|
||||
})
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
|
||||
const data = await load(segments, answers)
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
const { View } = inventoryPage
|
||||
return inProvider(
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
const platformRefusal = (status: number, code: string, detail: string) =>
|
||||
new AppError({ type: `/problems/${code}`, title: "Refused", status, code, detail })
|
||||
|
||||
describe("the inventory page", () => {
|
||||
it("lists the items and this project's reservations", async () => {
|
||||
const html = await render([], {
|
||||
"inventory.item.list": {
|
||||
rows: [{ itemId: ITEM, sku: "BOLT-8", name: "Bolt M8", unit: "pcs", description: null, active: true }],
|
||||
},
|
||||
"inventory.reservation.list": {
|
||||
rows: [
|
||||
{
|
||||
reservationId: "r1",
|
||||
itemId: ITEM,
|
||||
locationId: LOCATION,
|
||||
quantity: 3,
|
||||
outstanding: 3,
|
||||
released: 0,
|
||||
issued: 0,
|
||||
status: "open",
|
||||
createdAt: "2026-11-02T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(html).toContain(">Inventory</h1>")
|
||||
const link = html.match(/<a\b[^>]*>BOLT-8<\/a>/)?.[0] ?? ""
|
||||
expect(link).toContain('data-slot="row-link"')
|
||||
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
|
||||
expect(link).toContain(`href="/acme/ws/p/inventory/${ITEM}"`)
|
||||
expect(link).toContain('aria-label="Stock of BOLT-8"')
|
||||
expect(html).toContain('aria-label="Items"')
|
||||
expect(html).toContain('aria-label="Open reservations of this project"')
|
||||
expect(html).toContain("BOLT-8 Bolt M8")
|
||||
expect(html).toContain("SKU")
|
||||
expect(html).toContain('role="search"')
|
||||
})
|
||||
|
||||
it("shows an item's stock by location, and the form that reserves it for whoever may", async () => {
|
||||
const answers = {
|
||||
"inventory.item.availability": {
|
||||
item: { itemId: ITEM, sku: "BOLT-8", name: "Bolt M8", unit: "pcs", description: null, active: true },
|
||||
availability: {
|
||||
itemId: ITEM,
|
||||
unit: "pcs",
|
||||
onHand: 10,
|
||||
reserved: 3,
|
||||
available: 7,
|
||||
locations: [{ locationId: LOCATION, onHand: 10, reserved: 3, available: 7 }],
|
||||
},
|
||||
},
|
||||
}
|
||||
const html = await render([ITEM], answers)
|
||||
expect(html).toContain("Counted in pcs")
|
||||
expect(html).toContain("1 location with stock")
|
||||
expect(html).toContain('aria-label="Stock by location"')
|
||||
expect(html).toContain("7d1e2f3a")
|
||||
expect(html).toContain("Reserve for this project")
|
||||
expect(html).toContain("7d1e2f3a (7)")
|
||||
const reader = await render([ITEM], answers, ["inventory.item.read"])
|
||||
expect(reader).not.toContain("Reserve for this project")
|
||||
})
|
||||
|
||||
it("offers the item form only to a member who may write items", async () => {
|
||||
const answers = { "inventory.item.list": { rows: [] }, "inventory.reservation.list": { rows: [] } }
|
||||
expect(await render([], answers)).toContain("New item")
|
||||
const reader = await render([], answers, ["inventory.item.read"])
|
||||
expect(reader).not.toContain("New item")
|
||||
expect(reader).toContain("No items yet.")
|
||||
})
|
||||
|
||||
it("shows the kit's not-enabled state when the organization has not enabled the service", async () => {
|
||||
const refused = platformRefusal(404, "service_not_enabled", "Service admission refused: not_enabled.")
|
||||
const html = await render([], { "inventory.item.list": refused, "inventory.reservation.list": refused })
|
||||
expect(html).toContain('data-state="service_not_enabled"')
|
||||
expect(html).toContain("This service is not enabled for your organization.")
|
||||
expect(html).not.toContain('role="grid"')
|
||||
})
|
||||
|
||||
it("tells a Lithuanian reader in the kit's Lithuanian sentence", async () => {
|
||||
const lt = createI18n("lt", { lt: { inventory: catalogues.lt?.inventory ?? {} } })
|
||||
const refused = platformRefusal(404, "service_not_enabled", "Service admission refused: not_enabled.")
|
||||
const data = await load([], { "inventory.item.list": refused, "inventory.reservation.list": refused }, lt)
|
||||
expect(data).toMatchObject({
|
||||
kind: "state",
|
||||
notice: { code: "service_not_enabled", text: "Ši paslauga jūsų organizacijai neįjungta." },
|
||||
})
|
||||
const answers = { "inventory.item.list": { rows: [] }, "inventory.reservation.list": { rows: [] } }
|
||||
const list = await load([], answers, lt)
|
||||
expect(list.kind === "list" && list.itemForm.refusals.business_duplicate).toBe("Toks įrašas jau yra.")
|
||||
expect(list.kind === "list" && list.reservationList.refusals.business_reservation_state).toBe(
|
||||
"Šio rezervavimo dabartinėje būsenoje pakeisti negalima.",
|
||||
)
|
||||
})
|
||||
|
||||
it("shows a missing permission and a silent service in the kit's words", async () => {
|
||||
const scoped = platformRefusal(403, "service_permission_missing", "Service admission refused: permission_missing.")
|
||||
expect(await render([ITEM], { "inventory.item.availability": scoped })).toContain(
|
||||
"This app is not allowed to use this service. Ask an administrator to grant it.",
|
||||
)
|
||||
const silent = platformRefusal(503, "business_unavailable", "The service did not answer. Try again later.")
|
||||
const html = await render([ITEM], { "inventory.item.availability": silent })
|
||||
expect(html).toContain('data-state="business_unavailable"')
|
||||
expect(html).toContain("The service did not answer. Try again later.")
|
||||
})
|
||||
|
||||
it("leaves any other refusal, and a path it does not have, to the shell", async () => {
|
||||
await expect(
|
||||
render([ITEM], {
|
||||
"inventory.item.availability": new AppError({
|
||||
type: "/problems/not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
problem: { code: "not_found" },
|
||||
})
|
||||
await expect(render(["not-an-id"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render([ITEM, "more"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.inventory?.()).resolves.toBe(inventoryPage)
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({
|
||||
default: { schemaVersion: 1, selected: ["inventory"], everAdopted: ["inventory"] },
|
||||
}))
|
||||
Reference in New Issue
Block a user