@@ -0,0 +1,176 @@
|
||||
// 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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user