// The CMS write pipeline against the REAL runtime store spine — an // InMemoryDataStore wrapped by `wrapStoreWithMixinBehaviour` with a registry // built from the SAME derived tables the app registers. That's what makes these // assertions honest: derivation + validation come from @voltro/cms, and // tenant-scoping/stamping come from the spine, not a mock. Run with `voltro test`. // // Read the note on `registry` below before trusting the word "exactly": the // registry here is hand-built, and that is precisely the half a booted app gets // from discovery instead. import { describe, it, expect } from 'vitest' import { InMemoryDataStore, makeSchemaRegistry, wrapStoreWithMixinBehaviour, type RegistryTableLike, } from '@voltro/runtime' import { ContentValidationFailed, publish, saveDraft } from '@voltro/cms' import { blogPost } from '../content' import { blogEntities, pageEntities } from '../database/schema' // Registry over the real derived tables, built BY HAND here. // // It proves derivation, validation and the spine's stamping. It does NOT prove // that a booted app's registry CONTAINS these tables — this file supplies them // directly, while the app's registry is filled by discovery. That gap was not // theoretical: discovery only saw a schema module's direct exports, so these // four tables (exported nested, registered via `databaseHandle`) never reached // it, and every `saveDraft` died on `missing required column 'tenantId'` while // this suite stayed green. Fixed framework-side and guarded there // (`cli/src/handleOnlyTableDiscovery.test.ts`), which is the right level — but // if you copy this pattern into your own app's tests, know which half it // covers. const registry = makeSchemaRegistry([ blogEntities.draft, blogEntities.published, pageEntities.draft, pageEntities.published, ] as ReadonlyArray) // A request-scoped store for one tenant over a shared underlying store. const storeFor = (underlying: InMemoryDataStore, tenantId: string) => wrapStoreWithMixinBehaviour(underlying, { subject: { type: 'user', id: 'usr_editor', tenantId }, schemaRegistry: registry, }) const validInput = { title: 'Hello World', body: { type: 'doc', content: [{ type: 'paragraph' }] }, } describe('saveDraft → derive + validate + write', () => { it('derives the slug from the title, stamps status/tenant, generates an id', async () => { const store = storeFor(new InMemoryDataStore(), 'tnt_A') const draft = await saveDraft(store, blogPost, validInput) expect(draft['slug']).toBe('hello-world') // derivedFrom('title', slugify) expect(draft['status']).toBe('draft') expect(draft['tenantId']).toBe('tnt_A') // stamped by the spine expect(typeof draft['id']).toBe('string') }) it('rejects an invalid row with ContentValidationFailed carrying per-field violations', async () => { const store = storeFor(new InMemoryDataStore(), 'tnt_A') // Missing required `title` (so the derived slug is empty → pattern fails too). await expect(saveDraft(store, blogPost, { body: 'x' })).rejects.toBeInstanceOf(ContentValidationFailed) }) }) describe('publish → copy draft to published', () => { it('publishes a draft and the published row keeps the same id + slug', async () => { const store = storeFor(new InMemoryDataStore(), 'tnt_A') const draft = await saveDraft(store, blogPost, validInput) const published = await publish(store, blogPost, draft['id'] as string) expect(published['id']).toBe(draft['id']) expect(published['slug']).toBe('hello-world') expect(published['status']).toBe('published') }) }) describe('tenant isolation (REAL scoping)', () => { it("tenant B never sees tenant A's published content", async () => { const underlying = new InMemoryDataStore() const a = storeFor(underlying, 'tnt_A') const b = storeFor(underlying, 'tnt_B') const draft = await saveDraft(a, blogPost, validInput) await publish(a, blogPost, draft['id'] as string) const seenByA = await a.query({ table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined, }) const seenByB = await b.query({ table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined, }) expect(seenByA).toHaveLength(1) expect(seenByB).toHaveLength(0) }) })