Skip to content

Commit a27ffc2

Browse files
committed
test(plugin-email): pin a template send from an earlier plugin's kernel:ready
A real LiteKernel boot orders a stand-in for plugin-auth's dev seed (depends on ObjectQL only, registered ahead of the email plugin) whose kernel:ready hook sends `auth.verify_email` before the email plugin's own kernel:ready wiring. The send must render, persist its sys_email row at `sent` and deliver once, with no TEMPLATE_NOT_FOUND. Reuses this file's engine double. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6f0c77 commit a27ffc2

1 file changed

Lines changed: 112 additions & 0 deletions

File tree

‎packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import { describe, it, expect, vi, beforeEach } from 'vitest';
1616
import { assertEngineDeleteDispatch } from '@objectstack/objectql';
1717
import { DbQueueAdapter } from '@objectstack/service-queue';
18+
import { LiteKernel } from '@objectstack/core';
19+
import type { Plugin, PluginContext } from '@objectstack/core';
1820
import { EmailServicePlugin } from './email-plugin.js';
1921
import { EmailService, EMAIL_SEND_QUEUE } from './email-service.js';
2022
import { encodeAttachmentsForRow, encodeHeadersForRow } from './sys-email-payload.js';
@@ -493,3 +495,113 @@ describe('a datasource that re-keys a sys_email insert', () => {
493495
expect(h.sysEmail()[0]).toMatchObject({ id: 'db-pk-7', status: 'sent' });
494496
});
495497
});
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

Comments
 (0)