|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A notification template is resolved in the recipient's organization. |
| 5 | + * |
| 6 | + * `sys_notification_template` is organization-owned (ADR-0131: organization |
| 7 | + * ownership is total — the table carries `organization_id` and the |
| 8 | + * platform-object tenancy census lists it), so the row a delivery renders is |
| 9 | + * one the delivery's own organization authored. `NotificationTemplateStore` |
| 10 | + * keys its read by the delivery's organization, the spelling |
| 11 | + * `PreferenceResolver` scopes its rows by; a call that names no organization |
| 12 | + * reads no row. |
| 13 | + * |
| 14 | + * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3) holding two |
| 15 | + * organizations, under each walled posture, through the real email and SMS |
| 16 | + * channels: |
| 17 | + * |
| 18 | + * - each organization's delivery renders its own organization's row; |
| 19 | + * - an organization that authored no row renders the producer's own title |
| 20 | + * and body (the renderer's documented no-template fallback); |
| 21 | + * - a delivery that names no organization renders that same fallback. |
| 22 | + * |
| 23 | + * And the preservation half, under `single`: the sole organization's row |
| 24 | + * still renders for its own deliveries. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 28 | +import { ObjectQL } from '@objectstack/objectql'; |
| 29 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 30 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 31 | +import { NotificationTemplate } from './objects/index.js'; |
| 32 | +import { NotificationTemplateStore, TEMPLATE_OBJECT } from './template-renderer.js'; |
| 33 | +import { createEmailChannel } from './email-channel.js'; |
| 34 | +import { createSmsChannel } from './sms-channel.js'; |
| 35 | +import type { Delivery, MessagingChannel } from './channel.js'; |
| 36 | + |
| 37 | +// Declared as the platform declares them: better-auth owns both tables, so the |
| 38 | +// registration injects no organization column. |
| 39 | +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; |
| 40 | +const SYS_USER = { |
| 41 | + name: 'sys_user', |
| 42 | + label: 'User', |
| 43 | + managedBy: 'better-auth', |
| 44 | + fields: { |
| 45 | + name: { type: 'text', label: 'Name' }, |
| 46 | + email: { type: 'text', label: 'Email' }, |
| 47 | + phone_number: { type: 'text', label: 'Phone' }, |
| 48 | + locale: { type: 'text', label: 'Locale' }, |
| 49 | + }, |
| 50 | +}; |
| 51 | + |
| 52 | +const SYSTEM = { context: { isSystem: true } as ExecutionContext }; |
| 53 | +const TOPIC = 'deal.won'; |
| 54 | +const silentLogger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; |
| 55 | +silentLogger.child = () => silentLogger; |
| 56 | +const channelCtx = { logger: silentLogger }; |
| 57 | + |
| 58 | +let engine: ObjectQL | undefined; |
| 59 | + |
| 60 | +afterEach(async () => { |
| 61 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 62 | + engine = undefined; |
| 63 | +}); |
| 64 | + |
| 65 | +type Posture = 'isolated' | 'group' | 'single'; |
| 66 | + |
| 67 | +/** A real engine on `posture` holding `organizations`, and one recipient per organization. */ |
| 68 | +async function boot(posture: Posture, organizations: readonly string[]): Promise<ObjectQL> { |
| 69 | + const ql = new ObjectQL(); |
| 70 | + ql.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); |
| 71 | + await ql.init(); |
| 72 | + for (const def of [SYS_ORGANIZATION, SYS_USER, NotificationTemplate]) { |
| 73 | + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); |
| 74 | + } |
| 75 | + await ql.syncSchemas(); |
| 76 | + ql.setTenancyPostureProvider(() => posture); |
| 77 | + for (const [i, id] of organizations.entries()) { |
| 78 | + await ql.insert('sys_organization', { id, name: id }, SYSTEM); |
| 79 | + await ql.insert('sys_user', { id: `user_${id}`, name: id, email: `${id}@example.test`, phone_number: `+1555000000${i}`, locale: 'en' }, SYSTEM); |
| 80 | + } |
| 81 | + engine = ql; |
| 82 | + return ql; |
| 83 | +} |
| 84 | + |
| 85 | +/** The row `organization` authors in Setup for (TOPIC, channel, 'en'), written as that organization's write lands. */ |
| 86 | +async function author(ql: ObjectQL, organization: string, channel: 'email' | 'sms', text: string): Promise<void> { |
| 87 | + await ql.insert( |
| 88 | + TEMPLATE_OBJECT, |
| 89 | + { topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true }, |
| 90 | + { context: { isSystem: true, tenantId: organization } as ExecutionContext }, |
| 91 | + ); |
| 92 | +} |
| 93 | + |
| 94 | +/** What each row is stored with — the fixture's own control, so no pin passes on a mis-stamped row. */ |
| 95 | +async function storedOrganizations(ql: ObjectQL): Promise<string[]> { |
| 96 | + const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['channel', 'organization_id'] }, SYSTEM)) ?? []; |
| 97 | + return rows.map((r: Record<string, unknown>) => `${String(r.channel)}:${String(r.organization_id ?? 'NULL')}`).sort(); |
| 98 | +} |
| 99 | + |
| 100 | +/** The real email + SMS channels over `ql`, with transports that record what they were handed. */ |
| 101 | +function channels(ql: ObjectQL) { |
| 102 | + const sent: Array<{ channel: string; subject?: string; text: string }> = []; |
| 103 | + const store = new NotificationTemplateStore({ getData: () => ql as any }); |
| 104 | + const email = createEmailChannel({ |
| 105 | + getEmail: () => ({ async send(i: any) { sent.push({ channel: 'email', subject: i.subject, text: i.text ?? i.html }); return { id: 'e' }; } }) as any, |
| 106 | + getData: () => ql as any, |
| 107 | + store, |
| 108 | + }); |
| 109 | + const sms = createSmsChannel({ |
| 110 | + getSms: () => ({ async send(i: any) { sent.push({ channel: 'sms', text: i.body }); return { messageId: 's' }; } }) as any, |
| 111 | + getData: () => ql as any, |
| 112 | + store, |
| 113 | + }); |
| 114 | + return { sent, email, sms }; |
| 115 | +} |
| 116 | + |
| 117 | +function delivery(channel: 'email' | 'sms', organizationId: string | undefined, recipient: string): Delivery { |
| 118 | + return { |
| 119 | + channel, |
| 120 | + recipient, |
| 121 | + notification: { |
| 122 | + notificationId: 'evt_1', |
| 123 | + ...(organizationId !== undefined ? { organizationId } : {}), |
| 124 | + topic: TOPIC, |
| 125 | + title: 'Producer title', |
| 126 | + body: 'Producer body', |
| 127 | + recipients: [recipient], |
| 128 | + payload: { title: 'Producer title', body: 'Producer body' }, |
| 129 | + }, |
| 130 | + }; |
| 131 | +} |
| 132 | + |
| 133 | +async function send(ch: MessagingChannel, d: Delivery): Promise<void> { |
| 134 | + const result = await ch.send(channelCtx as any, d); |
| 135 | + expect(result.ok, `${d.channel} delivery reported failure: ${result.error}`).toBe(true); |
| 136 | +} |
| 137 | + |
| 138 | +describe.each(['isolated', 'group'] as const)("%s, two organizations: a template is resolved in the recipient's organization", (posture) => { |
| 139 | + it("each organization's delivery renders its own organization's row, on email and on SMS", async () => { |
| 140 | + const ql = await boot(posture, ['org_a', 'org_b']); |
| 141 | + for (const org of ['org_a', 'org_b']) { |
| 142 | + await author(ql, org, 'email', org); |
| 143 | + await author(ql, org, 'sms', org); |
| 144 | + } |
| 145 | + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'email:org_b', 'sms:org_a', 'sms:org_b']); |
| 146 | + |
| 147 | + const { sent, email, sms } = channels(ql); |
| 148 | + for (const org of ['org_a', 'org_b']) { |
| 149 | + await send(email, delivery('email', org, `${org}@example.test`)); |
| 150 | + await send(sms, delivery('sms', org, `user_${org}`)); |
| 151 | + } |
| 152 | + |
| 153 | + expect(sent).toEqual([ |
| 154 | + { channel: 'email', subject: 'org_a subject', text: 'org_a body' }, |
| 155 | + { channel: 'sms', text: 'org_a body' }, |
| 156 | + { channel: 'email', subject: 'org_b subject', text: 'org_b body' }, |
| 157 | + { channel: 'sms', text: 'org_b body' }, |
| 158 | + ]); |
| 159 | + }); |
| 160 | + |
| 161 | + it("an organization that authored no row renders the producer's own title and body", async () => { |
| 162 | + const ql = await boot(posture, ['org_a', 'org_b']); |
| 163 | + await author(ql, 'org_a', 'email', 'org_a'); |
| 164 | + await author(ql, 'org_a', 'sms', 'org_a'); |
| 165 | + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']); |
| 166 | + |
| 167 | + const { sent, email, sms } = channels(ql); |
| 168 | + await send(email, delivery('email', 'org_b', 'org_b@example.test')); |
| 169 | + await send(sms, delivery('sms', 'org_b', 'user_org_b')); |
| 170 | + |
| 171 | + expect(sent).toEqual([ |
| 172 | + { channel: 'email', subject: 'Producer title', text: 'Producer body' }, |
| 173 | + { channel: 'sms', text: 'Producer body' }, |
| 174 | + ]); |
| 175 | + }); |
| 176 | + |
| 177 | + it("a delivery that names no organization renders the producer's own title and body", async () => { |
| 178 | + const ql = await boot(posture, ['org_a', 'org_b']); |
| 179 | + await author(ql, 'org_a', 'email', 'org_a'); |
| 180 | + await author(ql, 'org_a', 'sms', 'org_a'); |
| 181 | + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']); |
| 182 | + |
| 183 | + const { sent, email, sms } = channels(ql); |
| 184 | + await send(email, delivery('email', undefined, 'org_b@example.test')); |
| 185 | + await send(sms, delivery('sms', undefined, 'user_org_b')); |
| 186 | + |
| 187 | + expect(sent).toEqual([ |
| 188 | + { channel: 'email', subject: 'Producer title', text: 'Producer body' }, |
| 189 | + { channel: 'sms', text: 'Producer body' }, |
| 190 | + ]); |
| 191 | + }); |
| 192 | +}); |
| 193 | + |
| 194 | +describe("single, one organization: the organization's own row still renders", () => { |
| 195 | + it("a delivery of the sole organization renders that organization's row, on email and on SMS", async () => { |
| 196 | + const ql = await boot('single', ['org_sole']); |
| 197 | + await author(ql, 'org_sole', 'email', 'org_sole'); |
| 198 | + await author(ql, 'org_sole', 'sms', 'org_sole'); |
| 199 | + expect(await storedOrganizations(ql)).toEqual(['email:org_sole', 'sms:org_sole']); |
| 200 | + |
| 201 | + const { sent, email, sms } = channels(ql); |
| 202 | + await send(email, delivery('email', 'org_sole', 'org_sole@example.test')); |
| 203 | + await send(sms, delivery('sms', 'org_sole', 'user_org_sole')); |
| 204 | + |
| 205 | + expect(sent).toEqual([ |
| 206 | + { channel: 'email', subject: 'org_sole subject', text: 'org_sole body' }, |
| 207 | + { channel: 'sms', text: 'org_sole body' }, |
| 208 | + ]); |
| 209 | + }); |
| 210 | +}); |
0 commit comments