|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// `EmailProviderSchema` (spec) ↔ `EMAIL_TRANSPORT_PROVIDERS` (this package) — #5104. |
| 4 | +// |
| 5 | +// The third side of one vocabulary. `mail-manifest-providers.contract.test.ts` |
| 6 | +// already holds the settings dropdown equal to the transports; this holds the |
| 7 | +// *authoring contract* equal to them too. All three describe "which providers |
| 8 | +// exist", and every time two of them were left to drift the result was a |
| 9 | +// deployment that accepted a provider it could not deliver through (#5094's |
| 10 | +// `sendgrid` / `ses`) or refused one it could (#5087's `smtp`, and the spec |
| 11 | +// enum this test was written for: an author annotating `objectstack.config.ts` |
| 12 | +// with `EmailServiceConfig` got a type error for `provider: 'smtp'` months |
| 13 | +// after the transport shipped). |
| 14 | +// |
| 15 | +// It is a CROSS-PACKAGE assertion for the reason spelled out in the manifest |
| 16 | +// contract test: two mirrored literals can always be "fixed" by editing the |
| 17 | +// other literal. `@objectstack/spec` is a real dependency of this package, so |
| 18 | +// the comparison costs nothing and no runtime edge is added — this file is |
| 19 | +// test-only. |
| 20 | +// |
| 21 | +// Note the asymmetry with the spec-side companion |
| 22 | +// (`packages/spec/src/system/email-config.test.ts`): that package excludes |
| 23 | +// `**/*.test.ts` from its `tsconfig.json`, so a type-level witness written |
| 24 | +// there is never compiled (#5286) and can only be a runtime check. This |
| 25 | +// package's tsconfig includes its tests, so the compile-time half below is |
| 26 | +// real — `pnpm --filter @objectstack/plugin-email typecheck` fails on a |
| 27 | +// mismatch before any test runs. |
| 28 | + |
| 29 | +import { describe, it, expect } from 'vitest'; |
| 30 | +import { EmailProviderSchema } from '@objectstack/spec/system'; |
| 31 | +import type { EmailProvider } from '@objectstack/spec/system'; |
| 32 | +import { |
| 33 | + makeTransport, |
| 34 | + EMAIL_TRANSPORT_PROVIDERS, |
| 35 | + RETIRED_EMAIL_PROVIDERS, |
| 36 | + type EmailTransportProvider, |
| 37 | +} from './index.js'; |
| 38 | + |
| 39 | +/** |
| 40 | + * Compile-time halves of the same invariant: each union must be assignable to |
| 41 | + * the other. A member added on one side only collapses its alias to `never`, |
| 42 | + * and `true` stops being assignable — a type error in this package's |
| 43 | + * `typecheck`, not a deferred test failure. |
| 44 | + */ |
| 45 | +type SpecAssignableToTransport = EmailProvider extends EmailTransportProvider ? true : never; |
| 46 | +type TransportAssignableToSpec = EmailTransportProvider extends EmailProvider ? true : never; |
| 47 | +const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true]; |
| 48 | + |
| 49 | +/** Minimal credentials that let each provider be built for real. */ |
| 50 | +const BUILD_ARGS: Record<string, Parameters<typeof makeTransport>[0]> = { |
| 51 | + log: { provider: 'log' }, |
| 52 | + resend: { provider: 'resend', apiKey: 're_test_key' }, |
| 53 | + postmark: { provider: 'postmark', apiKey: 'pm-test-key' }, |
| 54 | + smtp: { provider: 'smtp', options: { host: 'smtp.example.test' } }, |
| 55 | +}; |
| 56 | + |
| 57 | +describe('EmailProviderSchema ↔ EMAIL_TRANSPORT_PROVIDERS', () => { |
| 58 | + it('declares exactly the providers this package can materialise', () => { |
| 59 | + // Set equality, both directions at once: |
| 60 | + // ⊆ — no spec value without a transport (authors promised a provider |
| 61 | + // every boot would refuse); |
| 62 | + // ⊇ — no transport the spec hides (`smtp`, for three releases). |
| 63 | + expect(new Set(EmailProviderSchema.options)).toEqual(new Set(EMAIL_TRANSPORT_PROVIDERS)); |
| 64 | + }); |
| 65 | + |
| 66 | + it('agrees at the type level in both directions', () => { |
| 67 | + expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]); |
| 68 | + }); |
| 69 | + |
| 70 | + it('builds a real transport for every provider the spec declares', () => { |
| 71 | + // Set equality alone would be satisfied by two identically-wrong lists. |
| 72 | + // This is the direction that matters to an author: what the contract |
| 73 | + // permits, `makeTransport` delivers. |
| 74 | + for (const provider of EmailProviderSchema.options) { |
| 75 | + const args = BUILD_ARGS[provider]; |
| 76 | + expect(args, `no build recipe for spec provider '${provider}'`).toBeDefined(); |
| 77 | + expect(() => makeTransport(args), provider).not.toThrow(); |
| 78 | + } |
| 79 | + }); |
| 80 | + |
| 81 | + it('never re-admits a retired provider tag', () => { |
| 82 | + // `sendgrid` / `ses` have migration guidance rather than a transport |
| 83 | + // (#5094). Declaring either in the spec would tell authors to write a |
| 84 | + // value every boot path throws on — the same defect as the missing |
| 85 | + // `smtp`, pointed the other way. |
| 86 | + for (const retired of Object.keys(RETIRED_EMAIL_PROVIDERS)) { |
| 87 | + expect(EmailProviderSchema.safeParse(retired).success, retired).toBe(false); |
| 88 | + expect(EMAIL_TRANSPORT_PROVIDERS, retired).not.toContain(retired); |
| 89 | + } |
| 90 | + }); |
| 91 | +}); |
0 commit comments