@@ -0,0 +1,116 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian (spec §10.4), the
|
||||
// Lithuanian plural forms are real ones, and no component of the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { notificationLabels } from "../adapters/notifications.js"
|
||||
import { feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
|
||||
import { attachmentLabels, deleteLabels, resourceLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as the app has them: from the manifest, flattened and merged by the kit.
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const en = catalogues.en?.notes ?? {}
|
||||
const lt = catalogues.lt?.notes ?? {}
|
||||
|
||||
/** The keys a builder asks for, recorded by running it with a lookup that notes each one. */
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
resourceLabels(t)
|
||||
attachmentLabels(t)
|
||||
deleteLabels(t)
|
||||
notificationLabels(t, "Launch")
|
||||
feedbackLabels(t) // kit:guides
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the notes catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
})
|
||||
|
||||
it("hold no empty message", () => {
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the components' labels ask for, in both languages", () => {
|
||||
const asked = keysUsed()
|
||||
expect(asked.length).toBeGreaterThan(25)
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing notes.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing notes.${key}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it("hold no message nothing asks for, so a renamed label does not leave a dead string behind", () => {
|
||||
// The pages and the navigation ask for these directly, not through a label builder.
|
||||
const direct = new Set([
|
||||
"workspace.title",
|
||||
"workspace.intro",
|
||||
"workspace.filter",
|
||||
"workspace.apply",
|
||||
"workspace.clear",
|
||||
"workspace.noProjects",
|
||||
"workspace.empty",
|
||||
"workspace.selected",
|
||||
"title",
|
||||
"intro",
|
||||
"detail.back",
|
||||
"detail.changed",
|
||||
"detail.edit",
|
||||
])
|
||||
const asked = new Set([...keysUsed(), ...direct])
|
||||
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
const nav = appModules.find((module) => module.id === "notes")?.ui?.nav ?? []
|
||||
expect(nav.map((item) => item.label)).toEqual(["notes.title", "notes.workspace.title"])
|
||||
expect(en.title).toBeTruthy()
|
||||
expect(lt.title).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Lithuanian plural forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { notes: en }, lt: { notes: lt } }).message("notes.attachments.count", { count: n })
|
||||
|
||||
it("select one, few and other the way Lithuanian counts", () => {
|
||||
expect(count("lt", 1)).toBe("1 priedas")
|
||||
expect(count("lt", 2)).toBe("2 priedai")
|
||||
expect(count("lt", 9)).toBe("9 priedai")
|
||||
expect(count("lt", 10)).toBe("10 priedų")
|
||||
expect(count("lt", 21)).toBe("21 priedas")
|
||||
})
|
||||
|
||||
it("keep English to its two forms", () => {
|
||||
expect(count("en", 1)).toBe("1 attachment")
|
||||
expect(count("en", 2)).toBe("2 attachments")
|
||||
expect(lt["attachments.count"]).toContain("few {")
|
||||
expect(en["attachments.count"]).not.toContain("few {")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(4)
|
||||
for (const { name, text } of sources) expect(lintUiStrings(text, name), name).toEqual([])
|
||||
})
|
||||
|
||||
it("carry no colour literal: the brand tokens are the only source of colour", () => {
|
||||
for (const { name, text } of sources)
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: the rows it seeds in each tenant, and one fixture per tenant-scoped procedure. The suite
|
||||
// finds this file by its place, so a module added with `pnpm new:module` has its fixtures read
|
||||
// without a line outside its folder; a procedure with no fixture fails the suite by name.
|
||||
//
|
||||
// A fixture is built from the *other* tenant's ids: the call is made as a member of one
|
||||
// organization with another's slugs and ids, and has to be refused -- for being another tenant's, not
|
||||
// for naming nothing, which is why `seed` leaves a note, a file and a link in every tenant.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import { type IsolationFixture, type IsolationTenant, uploadViaProcedures } from "@conusai-kit/testing"
|
||||
|
||||
/** What a module's isolation file exports: the shape the suite reads, so it needs no import of the suite. */
|
||||
export interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
const noteOf = (t: IsolationTenant) => t.extra.noteId ?? NOWHERE
|
||||
|
||||
export const isolation: ModuleIsolation = {
|
||||
async seed(call, inProject) {
|
||||
const note = (await call.callByName("notes.note.create", { ...inProject, title: "Seed note", body: "Seed" })) as {
|
||||
noteId: string
|
||||
}
|
||||
const file = await uploadViaProcedures(call, inProject, {
|
||||
filename: "notes-seed.txt",
|
||||
contentType: "text/plain",
|
||||
bytes: new TextEncoder().encode("seed"),
|
||||
})
|
||||
const link = (await call.callByName("notes.attachment.add", {
|
||||
...inProject,
|
||||
noteId: note.noteId,
|
||||
fileId: file.fileId,
|
||||
})) as { attachmentId: string }
|
||||
return { noteId: note.noteId, noteFileId: file.fileId, attachmentId: link.attachmentId }
|
||||
},
|
||||
fixtures: {
|
||||
"notes.note.list": { input: (t) => inProjectOf(t) },
|
||||
"notes.note.get": { input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t) }) },
|
||||
// The live collection's read-back: another tenant's note id answers nothing.
|
||||
"notes.note.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [noteOf(t)] }) },
|
||||
"notes.note.create": { input: (t) => ({ ...inProjectOf(t), title: "Intruder", body: "x" }) },
|
||||
"notes.note.update": {
|
||||
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), title: "Intruder", body: "x", expectedVersion: 1 }),
|
||||
},
|
||||
"notes.note.delete": { input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t) }) },
|
||||
"notes.attachment.add": {
|
||||
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), fileId: t.extra.noteFileId ?? NOWHERE }),
|
||||
},
|
||||
"notes.attachment.remove": {
|
||||
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), attachmentId: t.extra.attachmentId ?? NOWHERE }),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// The module's migration is its own file and exactly `schemaStatements()` over its two entities
|
||||
// (decision AT-D20: the entity definitions are the one source of a column, so a hand-written column
|
||||
// cannot drift from the schema the module validates its rows with). A change to an entity changes
|
||||
// this snapshot, which is the moment a customer must see a new migration in review. The MariaDB
|
||||
// folder is the same tables in the other dialect, generated by `mysqlSchemaStatements` (spec §5.3:
|
||||
// the dialect differences are explicit, one numbered folder per dialect), and the MongoDB folder is
|
||||
// the same collections and indexes as declarative commands, generated by `mongoSchemaCommands`.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { mysqlSchemaStatements } from "@conusai-kit/data-kysely/mysql"
|
||||
import { MONGO_KERNEL_COMMANDS, mongoSchemaCommands } from "@conusai-kit/data-mongo"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { NOTES_ENTITIES, notesModule } from "../index.js"
|
||||
|
||||
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
/** The same for the MariaDB folder: `mysqlSchemaStatements` leads with the kernel's own two. */
|
||||
function mysqlTablesOf(entities: Parameters<typeof mysqlSchemaStatements>[0]): string {
|
||||
const kernel = mysqlSchemaStatements([]).length
|
||||
return `${mysqlSchemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
/** The same for the MongoDB folder: `mongoSchemaCommands` leads with the kernel's own collections. */
|
||||
function mongoCommandsOf(entities: Parameters<typeof mongoSchemaCommands>[0]): ReturnType<typeof mongoSchemaCommands> {
|
||||
return mongoSchemaCommands(entities).slice(MONGO_KERNEL_COMMANDS.length)
|
||||
}
|
||||
|
||||
describe("the migration of the notes module", () => {
|
||||
it("is its own file, exactly its two tables", async () => {
|
||||
await expect(tablesOf(NOTES_ENTITIES)).toMatchFileSnapshot("../migrations/0001_notes.sql")
|
||||
})
|
||||
|
||||
it("is declared by the manifest, so the migrate job finds it, and every table is row-level secured", () => {
|
||||
expect(notesModule.migrations).toEqual({
|
||||
postgres: "migrations",
|
||||
mariadb: "adapters/kysely/mysql/migrations",
|
||||
mongodb: "adapters/mongo/migrations",
|
||||
})
|
||||
const sql = readFileSync(new URL("../migrations/0001_notes.sql", import.meta.url), "utf8")
|
||||
for (const table of ["notes_note", "notes_attachment"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
|
||||
it("has the same two tables as MariaDB DDL, and no policies -- MariaDB has no row-level security", async () => {
|
||||
const sql = readFileSync(new URL("../adapters/kysely/mysql/migrations/0001_notes.sql", import.meta.url), "utf8")
|
||||
await expect(mysqlTablesOf(NOTES_ENTITIES)).toMatchFileSnapshot(
|
||||
"../adapters/kysely/mysql/migrations/0001_notes.sql",
|
||||
)
|
||||
for (const table of ["notes_note", "notes_attachment"]) {
|
||||
expect(sql).toContain(`create table \`${table}\``)
|
||||
}
|
||||
expect(sql).not.toContain("row level security")
|
||||
})
|
||||
|
||||
it("has the same two collections as MongoDB commands, each index leading with org_id (AT3.2)", () => {
|
||||
const file = new URL("../adapters/mongo/migrations/0001_notes.json", import.meta.url)
|
||||
// The file is data (Biome keeps its JSON formatting canonical), so the pin is structural:
|
||||
// exactly the commands `mongoSchemaCommands` generates for these entities, nothing else.
|
||||
const commands = JSON.parse(readFileSync(file, "utf8")) as Array<Record<string, unknown>>
|
||||
expect(commands).toEqual(mongoCommandsOf(NOTES_ENTITIES))
|
||||
const created = commands
|
||||
.filter((command) => "createCollection" in command)
|
||||
.map((command) => command.createCollection)
|
||||
expect(created).toEqual(["notes_attachment", "notes_note"])
|
||||
// MongoDB has no row-level security: the wall is the repository predicate plus these
|
||||
// org_id-leading indexes, and the contract suite of @conusai-kit/data-mongo is its proof.
|
||||
const indexes = commands.filter((command) => "createIndex" in command)
|
||||
for (const index of indexes) {
|
||||
const keys = Object.keys((index.createIndex as { keys: Record<string, 1> }).keys)
|
||||
expect(keys[0]).toBe("org_id")
|
||||
}
|
||||
// The validator is the entity's zod schema as $jsonSchema, carried by the file itself.
|
||||
const note = commands.find((command) => command.createCollection === "notes_note") as {
|
||||
validator: { $jsonSchema: { properties: Record<string, unknown>; required: string[] } }
|
||||
}
|
||||
expect(Object.keys(note.validator.$jsonSchema.properties)).toContain("org_id")
|
||||
expect(note.validator.$jsonSchema.required).toContain("org_id")
|
||||
})
|
||||
})
|
||||
@@ -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 { 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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// The notes page on F10: the worked example. Its loader reads through the caller and returns plain data
|
||||
// with every label translated; the browser receives that data as JSON, so it must survive the round trip.
|
||||
|
||||
import { notFound as notFoundError } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { notesPage } from "../ui/pages.js"
|
||||
|
||||
const slugs = { org: "acme", workspace: "general", project: "demo" }
|
||||
const i18n = {
|
||||
locale: "lt",
|
||||
message: (key: string, values?: Record<string, string | number>) =>
|
||||
`${key}${values ? ` ${JSON.stringify(values)}` : ""}`,
|
||||
date: (date: Date) => date.toISOString().slice(0, 10),
|
||||
}
|
||||
const load = (segments: readonly string[], call: (contract: unknown, input: unknown) => Promise<unknown>) =>
|
||||
notesPage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: { call } as never,
|
||||
i18n: i18n as never,
|
||||
container: {} as never,
|
||||
})
|
||||
|
||||
const note = {
|
||||
noteId: "note-1",
|
||||
version: 3,
|
||||
title: "Roles",
|
||||
body: "text",
|
||||
attachmentCount: 0,
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
updatedAt: "2026-11-02T10:00:00.000Z",
|
||||
}
|
||||
|
||||
describe("the notes page's loader", () => {
|
||||
it("returns the list, its first page and its labels as plain data", async () => {
|
||||
const call = vi.fn(async () => ({ notes: [note] }))
|
||||
const data = await load([], call)
|
||||
expect(call.mock.calls[0]?.[1 as never]).toEqual(slugs)
|
||||
expect(data).toMatchObject({ kind: "list", intro: "notes.intro", notes: [note] })
|
||||
if (data.kind !== "list") throw new Error("not the list")
|
||||
expect(data.labels.title).toBe("notes.title")
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
})
|
||||
|
||||
it("returns one note with its attachments as the live list's seed and its date already formatted", async () => {
|
||||
const call = vi.fn(async () => ({
|
||||
note,
|
||||
attachments: [
|
||||
{ attachmentId: "a1", fileId: "f1", filename: "a.pdf", contentType: "application/pdf", size: 2048 },
|
||||
],
|
||||
attachmentsEnabled: true,
|
||||
}))
|
||||
const data = await load(["note-1"], call)
|
||||
expect(call.mock.calls[0]?.[1 as never]).toEqual({ ...slugs, noteId: "note-1" })
|
||||
if (data.kind !== "note") throw new Error("not the note")
|
||||
expect(data.changed).toContain("2026-11-02")
|
||||
// The rows are the procedure's own: the live list reads the same shape again on a change.
|
||||
expect(data.attachments[0]).toMatchObject({ attachmentId: "a1", size: 2048 })
|
||||
// The count and the size keep their placeholders for the browser, which knows the live numbers.
|
||||
expect(data.attachmentLabels.count).toBe("notes.attachments.count")
|
||||
expect(data.attachmentLabels.sizeKb).toBe("notes.attachments.sizeKb")
|
||||
expect(data.note).toEqual({ noteId: "note-1", title: "Roles", body: "text" })
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
})
|
||||
|
||||
it("answers a path it does not know as not found, without reading", async () => {
|
||||
const call = vi.fn()
|
||||
await expect(load(["note-1", "extra"], call)).rejects.toMatchObject({ status: 404 })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
expect(notFoundError("no such page").status).toBe(404)
|
||||
})
|
||||
|
||||
it("hands a procedure's refusal on, for the shell to answer", async () => {
|
||||
await expect(
|
||||
load(["note-2"], async () => {
|
||||
throw notFoundError("no such note")
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
// The components' markup, rendered on the server the way a page renders it: what a screen reader and
|
||||
// a keyboard user get is in the HTML, so the accessibility basics are asserted there -- a name for
|
||||
// every control, a caption and scoped headers for every table, a status that says what a colour would.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { FrameProvider, SidePanelProvider } from "@conusai-kit/ui/patterns"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { NoteView } from "../contract/index.js"
|
||||
import { Attachments, attachmentLabels, DeleteNote, deleteLabels, NotesResource, resourceLabels } from "../ui/index.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { notes: catalogues.en?.notes ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
{node}
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const note = (over: Partial<NoteView> = {}): NoteView => ({
|
||||
noteId: "n1",
|
||||
title: "Kick-off",
|
||||
body: "Agenda",
|
||||
version: 3,
|
||||
createdBy: "u1",
|
||||
createdAt: "2026-04-01T08:00:00.000Z",
|
||||
updatedAt: "2026-04-02T08:00:00.000Z",
|
||||
attachmentCount: 2,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe("the notes resource", () => {
|
||||
const render = (notes: readonly NoteView[], permissions: readonly string[]) =>
|
||||
inProvider(
|
||||
<FrameProvider
|
||||
router={{
|
||||
pathname: "/acme/ws/p/notes",
|
||||
search: new URLSearchParams(),
|
||||
refresh: async () => undefined,
|
||||
navigate: () => undefined,
|
||||
}}
|
||||
permissions={permissions}
|
||||
>
|
||||
<SidePanelProvider>
|
||||
<NotesResource slugs={slugs} seed={notes} labels={resourceLabels(t)} intro="Short notes" locale="en" />
|
||||
</SidePanelProvider>
|
||||
</FrameProvider>,
|
||||
)
|
||||
const writer = ["notes.note.read", "notes.note.write", "notes.note.delete"]
|
||||
const html = render([note(), note({ noteId: "n2", title: "Retro" })], writer)
|
||||
|
||||
it("is a named table with a header for every column and one for each row", () => {
|
||||
expect(html).toContain('aria-label="note"')
|
||||
expect(html).toContain("Kick-off")
|
||||
expect(html).toContain("Retro")
|
||||
expect(html).toContain('data-guide="notes.list"')
|
||||
})
|
||||
|
||||
it("offers a new note to whoever may write one, and only to them", () => {
|
||||
expect(html).toContain("New note")
|
||||
expect(html).toContain('data-guide="notes.new"')
|
||||
expect(render([note()], ["notes.note.read"])).not.toContain("New note")
|
||||
})
|
||||
|
||||
it("says so in words when there is nothing to list", () => {
|
||||
expect(render([], writer)).toContain("No notes yet.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the attachments", () => {
|
||||
const render = (enabled: boolean, seed: Parameters<typeof Attachments>[0]["seed"]) =>
|
||||
inProvider(
|
||||
<Attachments
|
||||
slugs={slugs}
|
||||
noteId="n1"
|
||||
seed={seed}
|
||||
enabled={enabled}
|
||||
accept={["text/plain", "application/pdf"]}
|
||||
labels={attachmentLabels(t)}
|
||||
locale="en"
|
||||
/>,
|
||||
)
|
||||
const rows = [
|
||||
{
|
||||
attachmentId: "a1",
|
||||
fileId: "f1",
|
||||
filename: "minutes.txt",
|
||||
contentType: "text/plain",
|
||||
size: 2048,
|
||||
attachedAt: "2026-04-02T08:00:00.000Z",
|
||||
},
|
||||
]
|
||||
|
||||
it("lists each file in a captioned table and names each remove button by its file", () => {
|
||||
const html = render(true, rows)
|
||||
expect(html).toContain('aria-label="Attachments"')
|
||||
expect(html).toContain("minutes.txt")
|
||||
expect(html).toContain("2 KB")
|
||||
expect(html).toContain('aria-label="Remove minutes.txt"')
|
||||
expect(html).toContain('href="/api/v1/files.file.download?org=acme&workspace=ws&project=p&fileId=f1"')
|
||||
expect(html).toContain(">Download minutes.txt</a>")
|
||||
expect(html).toContain("1 attachment")
|
||||
})
|
||||
|
||||
it("labels its file input, describes it with the hint, and asks for the types it accepts", () => {
|
||||
const html = render(true, [])
|
||||
const id = /<input [^>]*\bid="([^"]+)"[^>]*\bname="file"/.exec(html)?.[1]
|
||||
expect(id).toBeTruthy()
|
||||
expect(html).toMatch(new RegExp(`<label [^>]*for="${id}"[^>]*>Attach a file</label>`))
|
||||
expect(html).toContain("aria-describedby=")
|
||||
expect(html).toContain('accept="text/plain,application/pdf"')
|
||||
expect(html).toContain("Nothing is attached.")
|
||||
})
|
||||
|
||||
it("offers no upload when the feature flag is off, and says why", () => {
|
||||
const html = render(false, [])
|
||||
expect(html).not.toContain('type="file"')
|
||||
expect(html).toContain("Attachments are switched off for this project.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the delete button", () => {
|
||||
it("asks first: the destructive button is not on the page until the person has said yes to a question", () => {
|
||||
const html = inProvider(
|
||||
<DeleteNote slugs={slugs} noteId="n1" listHref="/acme/ws/p/notes" labels={deleteLabels(t)} />,
|
||||
)
|
||||
expect(html).toContain("Delete note")
|
||||
expect(html).not.toContain("Yes, delete the note")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// The workspace page's strings as one record (F10): `load` hands over the message of a count only the browser
|
||||
// knows with its placeholder in, and the view's `formatMessage` picks the language's plural form.
|
||||
|
||||
import { Container } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
import { notesWorkspacePage, WORKSPACE_LABEL_KEYS } from "../ui/workspace-page.js"
|
||||
|
||||
const slugs = { org: "acme", workspace: "main" }
|
||||
const projects = [
|
||||
{ slug: "alpha", name: "Alpha" },
|
||||
{ slug: "beta", name: "Beta" },
|
||||
]
|
||||
const load = (locale: "en" | "lt", search: Record<string, string> = {}) =>
|
||||
notesWorkspacePage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search,
|
||||
caller: { call: async () => ({ projects }) } as never,
|
||||
i18n: createI18n(locale, {
|
||||
en: { notes: { ...en, "workspace.selected": en.workspace.selected } },
|
||||
lt: { notes: { ...lt, "workspace.selected": lt.workspace.selected } },
|
||||
} as never),
|
||||
container: new Container(),
|
||||
})
|
||||
|
||||
describe("the workspace page's labels", () => {
|
||||
it("cross the loader as plain strings, with the count's placeholder still in", async () => {
|
||||
const data = await load("en")
|
||||
expect(Object.keys(data.labels)).toEqual([...WORKSPACE_LABEL_KEYS])
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
expect(data.labels.selected).toBe(en.workspace.selected)
|
||||
})
|
||||
|
||||
it("filter on the server by `search`", async () => {
|
||||
const data = await load("en", { q: " BET " })
|
||||
expect(data.projects.map((project) => project.slug)).toEqual(["beta"])
|
||||
expect(data.query).toBe(" BET ")
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, "0 projects selected"],
|
||||
[1, "1 project selected"],
|
||||
[2, "2 projects selected"],
|
||||
])("read %d in English", (count, text) => {
|
||||
expect(formatMessage(en.workspace.selected, { count }, "en")).toBe(text)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, "Pasirinktas 1 projektas"],
|
||||
[21, "Pasirinktas 21 projektas"],
|
||||
[3, "Pasirinkti 3 projektai"],
|
||||
[10, "Pasirinkta 10 projektų"],
|
||||
[0, "Pasirinkta 0 projektų"],
|
||||
])("read %d in Lithuanian", (count, text) => {
|
||||
expect(formatMessage(lt.workspace.selected, { count }, "lt")).toBe(text)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user