177 lines
8.5 KiB
TypeScript
177 lines
8.5 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 { 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 { makeNotesApp, type NotesApp, type SeededProject } from "../../../test/support/notes-app.js"
|
|
import { notesDeps } from "../adapters/procedures.js"
|
|
import { createNote } from "../core/notes.js"
|
|
import { type NoteView, notesModule } from "../index.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")
|
|
})
|
|
})
|