|
15 | 15 | import { describe, it, expect, vi, beforeEach } from 'vitest'; |
16 | 16 | import { assertEngineDeleteDispatch } from '@objectstack/objectql'; |
17 | 17 | import { DbQueueAdapter } from '@objectstack/service-queue'; |
| 18 | +import { LiteKernel } from '@objectstack/core'; |
| 19 | +import type { Plugin, PluginContext } from '@objectstack/core'; |
18 | 20 | import { EmailServicePlugin } from './email-plugin.js'; |
19 | 21 | import { EmailService, EMAIL_SEND_QUEUE } from './email-service.js'; |
20 | 22 | import { encodeAttachmentsForRow, encodeHeadersForRow } from './sys-email-payload.js'; |
@@ -493,3 +495,113 @@ describe('a datasource that re-keys a sys_email insert', () => { |
493 | 495 | expect(h.sysEmail()[0]).toMatchObject({ id: 'db-pk-7', status: 'sent' }); |
494 | 496 | }); |
495 | 497 | }); |
| 498 | + |
| 499 | +// ── a send issued before this plugin's own kernel:ready (#23946) ──────────── |
| 500 | + |
| 501 | +/** |
| 502 | + * Registers what the email plugin resolves at `init`, in ObjectQL's slot (its |
| 503 | + * hard dependency): this file's engine as `objectql`, the manifest door, and a |
| 504 | + * `protocol` whose layered `email_template` list serves exactly what that door |
| 505 | + * was handed — the email plugin's own code-declared built-ins. |
| 506 | + */ |
| 507 | +class EngineFixturePlugin implements Plugin { |
| 508 | + name = 'com.objectstack.engine.objectql'; |
| 509 | + type = 'standard' as const; |
| 510 | + version = '1.0.0'; |
| 511 | + providesServices = ['objectql', 'manifest', 'protocol']; |
| 512 | + readonly engine = fakeEngine(); |
| 513 | + private readonly manifests: Array<Record<string, any>> = []; |
| 514 | + init(ctx: PluginContext): void { |
| 515 | + ctx.registerService('objectql', this.engine); |
| 516 | + ctx.registerService('manifest', { register: (m: Record<string, any>) => { this.manifests.push(m); } }); |
| 517 | + ctx.registerService('protocol', { |
| 518 | + getMetaItemsForExecution: async ({ type }: { type: string }) => ({ |
| 519 | + items: type === 'email_template' ? this.manifests.flatMap((m) => m.emailTemplates ?? []) : [], |
| 520 | + }), |
| 521 | + }); |
| 522 | + } |
| 523 | +} |
| 524 | + |
| 525 | +/** |
| 526 | + * Stands in for plugin-auth's dev admin seed: a plugin that depends on ObjectQL |
| 527 | + * only (as `AuthPlugin` does), is registered ahead of the email plugin (as the |
| 528 | + * served composition orders them), and from its own `kernel:ready` hook makes |
| 529 | + * the verification send plugin-auth hands better-auth. A throw is caught and |
| 530 | + * kept — better-auth's `runInBackgroundOrAwait` logs it and swallows it. |
| 531 | + */ |
| 532 | +class EarlySenderPlugin implements Plugin { |
| 533 | + name = 'test.early-verification-sender'; |
| 534 | + type = 'standard' as const; |
| 535 | + version = '1.0.0'; |
| 536 | + dependencies = ['com.objectstack.engine.objectql']; |
| 537 | + readonly seen: { drainHooksAtSend?: number; result?: { id: string; status: string }; error?: unknown } = {}; |
| 538 | + constructor(private readonly engine: ReturnType<typeof fakeEngine>) {} |
| 539 | + init(): void { /* nothing to register */ } |
| 540 | + start(ctx: PluginContext): void { |
| 541 | + ctx.hook('kernel:ready', async () => { |
| 542 | + // The email plugin installs its outbox drain from ITS kernel:ready, so |
| 543 | + // "none yet" is the measured proof that this send runs ahead of it. |
| 544 | + this.seen.drainHooksAtSend = this.engine.afterInsertHooks().length; |
| 545 | + try { |
| 546 | + const email = ctx.getService<EmailService>('email'); |
| 547 | + this.seen.result = await email.sendTemplate({ |
| 548 | + template: 'auth.verify_email', |
| 549 | + to: { address: 'admin@example.test', name: 'Dev Admin' }, |
| 550 | + data: { |
| 551 | + user: { name: 'Dev Admin', email: 'admin@example.test', id: 'usr_seed' }, |
| 552 | + verificationUrl: 'https://example.test/verify?token=t1', |
| 553 | + token: 't1', |
| 554 | + expiresInMinutes: 60, |
| 555 | + appName: 'Acme', |
| 556 | + }, |
| 557 | + relatedObject: 'sys_user', |
| 558 | + relatedId: 'usr_seed', |
| 559 | + }); |
| 560 | + } catch (err) { |
| 561 | + this.seen.error = err; |
| 562 | + } |
| 563 | + }); |
| 564 | + } |
| 565 | +} |
| 566 | + |
| 567 | +describe('a template send from an earlier-starting plugin\'s kernel:ready (#23946)', () => { |
| 568 | + it('renders, persists its sys_email row and delivers once — no TEMPLATE_NOT_FOUND', async () => { |
| 569 | + const transport = { send: vi.fn(async (_m: Record<string, any>) => ({ messageId: '<early@x>' })) }; |
| 570 | + const kernel = new LiteKernel({ logger: { level: 'error' } }); |
| 571 | + const fixture = new EngineFixturePlugin(); |
| 572 | + const sender = new EarlySenderPlugin(fixture.engine); |
| 573 | + const plugin = new EmailServicePlugin({ transport, defaultFrom: { address: 'no-reply@example.test' } }); |
| 574 | + kernel.use(fixture); |
| 575 | + kernel.use(sender); |
| 576 | + kernel.use(plugin); |
| 577 | + await kernel.bootstrap(); |
| 578 | + await plugin.outboxSweepSettled; |
| 579 | + await flush(); |
| 580 | + |
| 581 | + // POSITIVE CONTROL — the send really ran before the email plugin's own |
| 582 | + // kernel:ready wiring, and that wiring did run afterwards. |
| 583 | + expect(sender.seen.drainHooksAtSend).toBe(0); |
| 584 | + expect(fixture.engine.afterInsertHooks()).toHaveLength(1); |
| 585 | + |
| 586 | + // THE PIN. Before the fix this was `TEMPLATE_NOT_FOUND: no templateLoader |
| 587 | + // configured on EmailService`, and no row. |
| 588 | + expect(sender.seen.error).toBeUndefined(); |
| 589 | + expect(sender.seen.result).toMatchObject({ status: 'sent' }); |
| 590 | + const rows = fixture.engine.rows('sys_email'); |
| 591 | + expect(rows).toHaveLength(1); |
| 592 | + expect(rows[0]).toMatchObject({ |
| 593 | + id: sender.seen.result?.id, |
| 594 | + status: 'sent', |
| 595 | + message_id: '<early@x>', |
| 596 | + subject: 'Verify your Acme email address', |
| 597 | + to_addresses: 'Dev Admin <admin@example.test>', |
| 598 | + related_object: 'sys_user', |
| 599 | + related_id: 'usr_seed', |
| 600 | + }); |
| 601 | + // Delivered once: the boot sweep that runs after it finds nothing stranded. |
| 602 | + expect(transport.send).toHaveBeenCalledTimes(1); |
| 603 | + expect(transport.send.mock.calls[0][0]).toMatchObject({ subject: 'Verify your Acme email address' }); |
| 604 | + |
| 605 | + await kernel.shutdown(); |
| 606 | + }); |
| 607 | +}); |
0 commit comments