Files
template-app/test/notes-notification.test.ts
2026-11-14 09:56:45 +00:00

177 lines
8.6 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A module event delivered through the transactional outbox, end to end: `notes.NoteCreated` is written by
// the transaction that writes the note, the relay hands it to the notes module's subscriber in the relay's
// own transaction (`NOTIFICATIONS_REPOS`), and the subscriber tells the project's other members through the
// notifications kit. What is proven here: one inbox row per record and recipient, also when a delivery is
// repeated (the record-derived key), never for the author, and none when the note's transaction rolls back.
// The relay's own exactly-once claim (Postgres-family profiles) is proven in kit-jobs-pgboss, not here.
import { type NoteView, notesModule } from "@app/modules/notes"
import type { EventContext, TenantScope } from "@conusai-kit/core"
import { systemActor } from "@conusai-kit/core/worker"
import type { DataSource } from "@conusai-kit/data"
import { FILES_SCOPE } from "@conusai-kit/files"
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
import { describe, expect, it } from "vitest"
import { notesDeps } from "../modules/notes/adapters/procedures.js"
import { createNote } from "../modules/notes/core/notes.js"
import { makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
interface Inbox {
items: Array<{ code: string; category: string; key: string; title: string | null; href: string | null }>
}
/** A restricted project: its creator is its first member, and the second person is added as an editor. */
async function restrictedProject(app: NotesApp) {
const seeded = await app.seedProject("acme", "owner@acme.test")
await seeded.call.callByName("tenancy.project.create", {
org: seeded.slugs.org,
workspace: seeded.slugs.workspace,
slug: "launch",
name: "Launch",
moduleKey: "notes",
visibility: "restricted",
})
const slugs = { ...seeded.slugs, project: "launch" }
const member = await app.addMember(seeded, "member@acme.test", "member")
await seeded.call.callByName("tenancy.project.set-member", {
...slugs,
userId: member.actor.userId,
roleKey: "editor",
})
return { seeded, slugs, member }
}
/** The notes module's entries of a person's inbox; the kit's own (an organization ready, an invitation accepted) are not. */
const inboxOf = async (call: SeededProject["call"], org: string) => {
const inbox = (await call.callByName("notifications.inbox.list", { org })) as Inbox
return { items: inbox.items.filter((item) => item.key.startsWith("notes:")) }
}
describe("notes.NoteCreated, through the outbox, to the project's other members", () => {
it("gives the project's other member one inbox row with a link to the note, and the author none", async () => {
const app = makeNotesApp()
const { seeded, slugs, member } = await restrictedProject(app)
const note = (await member.call.callByName("notes.note.create", {
...slugs,
title: "Kick-off",
body: "",
})) as NoteView
// Nothing until the relay runs: the event is a record of the note's transaction, not a side effect of the request.
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
await app.deliver()
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([
expect.objectContaining({
category: "notice",
code: "custom",
key: `notes:${note.noteId}:created`,
title: "New note in Launch",
href: `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${note.noteId}`,
}),
])
expect((await inboxOf(member.call, slugs.org)).items).toEqual([])
// The record is published: a second pass delivers nothing, and the inbox still holds one row.
expect(await app.deliver()).toBe(0)
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
})
it("is the same notification when the relay delivers a record again after a crash", async () => {
const app = makeNotesApp()
const { seeded, slugs, member } = await restrictedProject(app)
await member.call.callByName("notes.note.create", { ...slugs, title: "Kick-off", body: "" })
const record = (await app.data.outbox.pending(10)).find((entry) => entry.name === "notes.NoteCreated")
if (!record) throw new Error("expected the note's outbox record")
const scope: TenantScope = { level: "org", actor: systemActor("app.relay"), orgId: record.orgId }
const subscribers = app.registry.events["notes.NoteCreated"]?.subscribers ?? []
const deliverOnce = async () => {
for (const subscriber of subscribers) {
const container = app.root.child()
container.value(FILES_SCOPE, scope)
container.value(NOTIFICATIONS_REPOS, app.data.forScope(scope))
const ctx: EventContext = { container, correlationId: record.id }
await subscriber.handler({ name: record.name, payload: record.payload }, ctx)
}
}
await deliverOnce()
await deliverOnce()
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
})
it("tells the other member, not the author, when the project's creator writes the note", async () => {
const app = makeNotesApp()
const { seeded, slugs, member } = await restrictedProject(app)
await seeded.call.callByName("notes.note.create", { ...slugs, title: "Plan", body: "" })
await app.deliver()
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
expect((await inboxOf(member.call, slugs.org)).items).toHaveLength(1)
})
it("writes the title in the recipient's own language", async () => {
const app = makeNotesApp()
const { seeded, slugs, member } = await restrictedProject(app)
await seeded.call.callByName("tenancy.me.update", { locale: "lt" })
await member.call.callByName("notes.note.create", { ...slugs, title: "Planas", body: "" })
await app.deliver()
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.title)).toEqual([
"Naujas užrašas projekte „Launch“",
])
})
it("tells nobody in a project without named members, where the whole workspace would be noise", async () => {
const app = makeNotesApp()
const seeded = await app.seedProject("acme", "owner@acme.test")
const member = await app.addMember(seeded, "member@acme.test", "member")
await member.call.callByName("notes.note.create", { ...seeded.slugs, title: "Open", body: "" })
await app.deliver()
expect((await inboxOf(seeded.call, seeded.slugs.org)).items).toEqual([])
})
it("publishes nothing when the note's transaction rolls back, so nobody is told about a note that is not there", async () => {
const app = makeNotesApp()
const { seeded, slugs, member } = await restrictedProject(app)
await member.call.callByName("notes.note.create", { ...slugs, title: "Kept", body: "" })
const [kept] = await app.data.outbox.pending(500).then((all) => all.filter((r) => r.name === "notes.NoteCreated"))
if (!kept) throw new Error("expected the note's outbox record")
await app.deliver()
const { noteId, orgId, workspaceId, projectId } = kept.payload as {
noteId: string
orgId: string
workspaceId: string
projectId: string
}
const scope = { level: "project" as const, actor: member.actor, orgId, workspaceId, projectId }
// The commit fails after the use case wrote the row and the record: both go, or neither does.
const failing: DataSource = {
...app.data,
unitOfWork: {
run: (s, fn) =>
app.data.unitOfWork.run(s, async (tx) => {
await fn(tx)
throw new Error("the commit failed")
}),
},
}
await expect(
createNote({ ...notesDeps(app.root), data: failing }, scope, { title: "Ghost", body: "" }),
).rejects.toThrow("the commit failed")
expect(await app.data.outbox.pending(500)).toEqual([])
expect(await app.deliver()).toBe(0)
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.key)).toEqual([`notes:${noteId}:created`])
const titles = ((await member.call.callByName("notes.note.list", slugs)) as { notes: NoteView[] }).notes
expect(titles.map((n) => n.title)).toEqual(["Kept"])
})
it("has one subscription per module and event, because the relay claims a delivery per record and module", () => {
// A second subscription of one module to one event would be claimed by the first and never run.
const app = makeNotesApp()
for (const [name, entry] of Object.entries(app.registry.events)) {
const modules = entry.subscribers.map((subscriber) => subscriber.module)
expect(modules, name).toEqual([...new Set(modules)])
}
expect(notesModule.requires).toContain("notifications")
})
})