This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The whole app of this template on the in-memory data source, wired the way apps/web and apps/worker
|
||||
// wire it, for the notes module's tests: the registry of every module, one caller per actor, the
|
||||
// outbox relay's behaviour and the worker's job runner.
|
||||
//
|
||||
// `deliver()` stands in for the outbox relay: it drains the outbox once, runs each record's
|
||||
// subscribers in a child container with the record's organization and a system actor, and marks it
|
||||
// published -- exactly once per subscriber, never more forgiving than the real relay. `runJobs()`
|
||||
// stands in for the worker's job runner. What neither does is retry, so a test of a redelivery
|
||||
// runs a handler again itself.
|
||||
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import {
|
||||
createMemoryDataSource,
|
||||
DATA_SOURCE,
|
||||
entitiesOf,
|
||||
JOB_QUEUE,
|
||||
OBJECT_STORE,
|
||||
SYSTEM_DATA,
|
||||
} from "@conusai-kit/data"
|
||||
import { FILES_AUDIT, FILES_REGISTRY, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
|
||||
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
fakeJobQueue,
|
||||
fakeObjectStore,
|
||||
recordingAudit,
|
||||
recordingUsage,
|
||||
uploadViaProcedures,
|
||||
} from "@conusai-kit/testing"
|
||||
import { appModules } from "../../modules/index.js"
|
||||
import { NOTES_FLAGS, type NotesFlags } from "../../modules/notes/index.js"
|
||||
|
||||
export interface SeededProject {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
/** Standard base64 of some text, for the upload procedure's body. */
|
||||
export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text)
|
||||
|
||||
export function makeNotesApp(options: { flags?: NotesFlags } = {}) {
|
||||
const registry = buildRegistry([...appModules])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const store = fakeObjectStore()
|
||||
const queue = fakeJobQueue()
|
||||
const audit = recordingAudit()
|
||||
const usage = recordingUsage()
|
||||
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, store)
|
||||
root.value(JOB_QUEUE, queue)
|
||||
root.value(FILES_REGISTRY, registry)
|
||||
root.value(FILES_AUDIT, audit)
|
||||
root.value(FILES_USAGE, usage)
|
||||
root.value(PROPOSALS_REGISTRY, registry)
|
||||
if (options.flags) root.value(NOTES_FLAGS, options.flags)
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy }, { 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 }
|
||||
}
|
||||
|
||||
/** Runs every subscriber of every record in the outbox, once each; answers how many ran. */
|
||||
const deliver = async (): Promise<number> => {
|
||||
let ran = 0
|
||||
for (const record of await data.outbox.pending(500)) {
|
||||
const entry = registry.events[record.name]
|
||||
for (const subscriber of entry?.subscribers ?? []) {
|
||||
const container = root.child()
|
||||
container.value(FILES_SCOPE, { level: "org", actor: systemActor("app.relay"), orgId: record.orgId })
|
||||
await subscriber.handler(
|
||||
{ name: record.name, payload: record.payload },
|
||||
{ container, correlationId: record.id },
|
||||
)
|
||||
ran += 1
|
||||
}
|
||||
await data.outbox.markPublished([record.id])
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
/** The jobs not yet run, in the order they were enqueued. */
|
||||
let consumed = 0
|
||||
const runJobs = async (): Promise<number> => {
|
||||
let ran = 0
|
||||
while (consumed < queue.jobs.length) {
|
||||
const job = queue.jobs[consumed] as (typeof queue.jobs)[number]
|
||||
consumed += 1
|
||||
const registered = registry.jobs[job.job]
|
||||
if (!registered) throw new Error(`no module declares the job ${job.job}`)
|
||||
const container = root.child()
|
||||
container.value(TENANCY, createTenancyHooks({ data, registry }))
|
||||
container.value(FILES_SCOPE, { ...job.scope, actor: systemActor(job.job) })
|
||||
await registered.def.handler(registered.def.payload.parse(job.payload), {
|
||||
container,
|
||||
correlationId: job.id,
|
||||
attempt: 1,
|
||||
})
|
||||
ran += 1
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
async function seedProject(slug: string, email: string): Promise<SeededProject> {
|
||||
const owner = await signUp(email)
|
||||
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 { slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "notes",
|
||||
})) as { slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A signed-up user who joined the project's organization with a role. */
|
||||
async function addMember(project: SeededProject, email: string, role: "viewer" | "member" | "admin") {
|
||||
const actor = await signUp(email)
|
||||
const invite = (await project.call.callByName("tenancy.member.invite", {
|
||||
org: project.orgSlug,
|
||||
email,
|
||||
role,
|
||||
})) as { token: string }
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: project.orgSlug, token: invite.token })
|
||||
return { actor, call }
|
||||
}
|
||||
|
||||
/** Uploads a small text file through the files module, the way the page's form does. */
|
||||
async function upload(project: SeededProject, filename = "minutes.txt", text = "agreed") {
|
||||
return await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: filename,
|
||||
contentType: "text/plain",
|
||||
bytes: bytesOf(text),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
/** The process container the procedures resolve their ports from (a seed script needs it). */
|
||||
root,
|
||||
data,
|
||||
entities,
|
||||
store,
|
||||
queue,
|
||||
audit,
|
||||
usage,
|
||||
caller,
|
||||
signUp,
|
||||
deliver,
|
||||
runJobs,
|
||||
seedProject,
|
||||
addMember,
|
||||
upload,
|
||||
}
|
||||
}
|
||||
|
||||
export type NotesApp = ReturnType<typeof makeNotesApp>
|
||||
Reference in New Issue
Block a user