[grade=A] Welcome email for first purchases: order-independent classification + premium walkthrough
- invoice.paid classified welcome-vs-renewal via emailDeliveredAt (durable, webhook-order independent) through new classifySubscriptionEmail() - the old isNewLicense/row-existence signal broke under Stripe's real event order (subscription.created pre-creates the row before the first invoice). - renderLicenseEmail() extracted pure; first purchases now get 'Welcome to DashCaddy Premium' with unlocks panel (SSO/Recipes/Swarm/auto-renew) and 3-step getting-started; renewal copy byte-identical (test-pinned). - 9 new tests (37 total): behavioral classifier coverage, rendered-content assertions both variants, store flag regression, mkdtemp-sandboxed. - Codex judge: A, zero blockers (r5; prior rounds D-D-D-C drove the fixes). Verdict: run output 'A' with empty Blockers (direct codex exec, 2026-09-22).
This commit is contained in:
8 files changed
+304
-6
No files matched your search
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
// Behavioral tests for the welcome-vs-renewal classification (the exact
|
||||
// decision invoice.paid makes), through the exported pure classifier.
|
||||
|
||||
const { classifySubscriptionEmail } = await import('../src/licenseLogic.js');
|
||||
|
||||
// Simulated invoice.paid behavior against progressively-delivered license
|
||||
// state — no SMTP, no Stripe, no handler spin-up required; this is the same
|
||||
// state machine deliverAndTrackLicenseEmail drives via updateLicense().
|
||||
test('classification behavior across the real webhook order', () => {
|
||||
const uniq = `classify_${Date.now()}`;
|
||||
const license = {
|
||||
id: `lic_${uniq}`,
|
||||
key: 'DC-20F00-0000D-NB3FN-S6HXJ-A7CA0',
|
||||
customerId: `cus_${uniq}`,
|
||||
subscriptionId: `sub_${uniq}`,
|
||||
emailDeliveredAt: null
|
||||
};
|
||||
|
||||
// State 1: license row exists (subscription.created already processed),
|
||||
// no email ever delivered -> invoice.paid must send the WELCOME email.
|
||||
assert.equal(classifySubscriptionEmail(license).extended, false,
|
||||
'pre-created license with zero delivered emails = welcome');
|
||||
|
||||
// State 2: welcome email delivered (deliverAndTrackLicenseEmail sets this
|
||||
// via updateLicense after SMTP 250). Next invoice.paid = renewal.
|
||||
license.emailDeliveredAt = new Date().toISOString();
|
||||
assert.equal(classifySubscriptionEmail(license).extended, true,
|
||||
'license with a delivered email = extended/renewal');
|
||||
});
|
||||
|
||||
test('classification survives missing/blank email state', () => {
|
||||
assert.equal(classifySubscriptionEmail(null).extended, false,
|
||||
'null license cannot have delivered mail -> welcome');
|
||||
assert.equal(classifySubscriptionEmail({}).extended, false,
|
||||
'missing field -> welcome');
|
||||
assert.equal(classifySubscriptionEmail({ emailDeliveredAt: '' }).extended, false,
|
||||
'blank timestamp -> welcome');
|
||||
assert.equal(classifySubscriptionEmail({ emailDeliveredAt: '2026-09-22T00:00:00.000Z' }).extended, true,
|
||||
'real timestamp -> extended');
|
||||
});
|
||||
|
||||
// The server must call the classifier, not inline its own logic.
|
||||
test('server.js delegates classification to classifySubscriptionEmail', () => {
|
||||
const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
|
||||
assert.ok(src.includes('classifySubscriptionEmail(license).extended'),
|
||||
'handler must use the shared classifier');
|
||||
assert.ok(!src.includes('Boolean(license.emailDeliveredAt)'),
|
||||
'no inline duplicate of the logic');
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
// Render-only tests: no SMTP, no store. email.js imports only nodemailer.
|
||||
const { renderLicenseEmail } = await import('../src/email.js');
|
||||
|
||||
const base = {
|
||||
code: 'DC-20F00-0000D-NB3FN-S6HXJ-A7CA0',
|
||||
durationDays: 30,
|
||||
planCode: 'premium_30d',
|
||||
expiresAt: '2026-10-21T23:33:00.000Z'
|
||||
};
|
||||
|
||||
test('first purchase renders WELCOME email with walkthrough', () => {
|
||||
const mail = renderLicenseEmail({ ...base, extended: false });
|
||||
|
||||
assert.equal(mail.subject, 'Your DashCaddy Premium license key',
|
||||
'first purchase subject must NOT say "extended"');
|
||||
assert.ok(!/extended/i.test(mail.subject), 'subject must contain no extended wording');
|
||||
assert.ok(mail.html.includes('Welcome to DashCaddy Premium'), 'welcome headline');
|
||||
assert.ok(mail.html.includes('What your subscription unlocks'), 'unlocks panel');
|
||||
assert.ok(mail.html.includes('Auto-Login SSO'), 'feature: SSO');
|
||||
assert.ok(mail.html.includes('Recipes'), 'feature: Recipes');
|
||||
assert.ok(mail.html.includes('Swarm orchestration'), 'feature: Swarm');
|
||||
assert.ok(mail.html.includes('Automatic renewals'), 'feature: auto-renewal value prop');
|
||||
assert.ok(mail.html.includes('Getting started takes about a minute'), 'getting-started steps');
|
||||
assert.ok(mail.html.includes('Admin'), 'activation path shown');
|
||||
assert.ok(mail.text.includes('YOUR SUBSCRIPTION INCLUDES:'), 'plain text unlocks block');
|
||||
assert.ok(mail.text.includes('GETTING STARTED:'), 'plain text steps block');
|
||||
assert.equal((mail.text.match(/To activate:/g) || []).length, 0,
|
||||
'welcome email replaces the bare activation line with the 3-step block');
|
||||
assert.ok(mail.text.includes('Paste your license key and click Activate'),
|
||||
'activation still covered, inside the getting-started steps');
|
||||
assert.equal((mail.html.match(/data:image/g) || []).length, 2,
|
||||
'two embedded logos (DashCaddy header + samiahmed7777 footer)');
|
||||
});
|
||||
|
||||
test('renewal email keeps the extended copy and has NO welcome content', () => {
|
||||
const mail = renderLicenseEmail({ ...base, extended: true });
|
||||
|
||||
assert.equal(mail.subject, 'Your DashCaddy Premium license has been extended (30 days added)');
|
||||
assert.ok(mail.html.includes('License extended'), 'extended headline');
|
||||
assert.ok(!mail.html.includes('Welcome to DashCaddy Premium'), 'no welcome headline');
|
||||
assert.ok(!mail.html.includes('What your subscription unlocks'), 'no unlocks panel');
|
||||
assert.ok(!mail.html.includes('Getting started takes about a minute'), 'no getting-started steps');
|
||||
assert.ok(!mail.text.includes('YOUR SUBSCRIPTION INCLUDES:'), 'plain text: no unlocks block');
|
||||
assert.ok(!mail.text.includes('GETTING STARTED:'), 'plain text: no steps block');
|
||||
assert.equal((mail.text.match(/To activate:/g) || []).length, 1,
|
||||
'renewal keeps its single activation line');
|
||||
});
|
||||
|
||||
test('render matches production send path (sendLicenseEmail wraps renderLicenseEmail)', async () => {
|
||||
const src = fs.readFileSync(new URL('../src/email.js', import.meta.url), 'utf8');
|
||||
assert.ok(src.includes('renderLicenseEmail({ code, durationDays, planCode, expiresAt, extended })'),
|
||||
'sendLicenseEmail must delegate to renderLicenseEmail — one shared code path');
|
||||
assert.ok(src.includes('export async function sendLicenseEmail'),
|
||||
'send path still exported');
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
// Sandbox BEFORE importing store.js — never bind the test to production data.
|
||||
const dataDir = fs.mkdtempSync(`${process.env.TMPDIR || '/tmp'}/dashcaddy-license-order-test-`);
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
const store = await import('../src/store.js');
|
||||
|
||||
// Production webhook order: Stripe delivers customer.subscription.created BEFORE
|
||||
// the first invoice.paid (verified live 2026-09-22: sub created 23:06:08, invoice
|
||||
// events 23:06:12). The license row therefore exists when invoice.paid arrives.
|
||||
// The welcome-vs-renewal classification MUST survive both arrival orders.
|
||||
test('welcome classification is webhook-order independent', () => {
|
||||
const uniq = `ordertest_${Date.now()}`;
|
||||
const customerId = `cus_${uniq}`;
|
||||
const subscriptionId = `sub_${uniq}`;
|
||||
const subPatch = {
|
||||
subscriptionId,
|
||||
customerId,
|
||||
customerEmail: `${uniq}@example.com`,
|
||||
planCode: 'premium_30d',
|
||||
durationDays: 30,
|
||||
status: 'active',
|
||||
premiumFeatures: { sso: true, recipes: true, swarm: true },
|
||||
deactivatedAt: null
|
||||
};
|
||||
|
||||
// ORDER A (production): subscription.created pre-creates the license...
|
||||
const preCreated = store.createOrUpdateLicenseBySubscription(subscriptionId, {
|
||||
...subPatch,
|
||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
||||
lastStripeEventCreated: 1,
|
||||
lastStripeEventId: `evt_${uniq}_sub_created`
|
||||
});
|
||||
assert.equal(preCreated.isNewLicense, true);
|
||||
|
||||
// ...then invoice.paid arrives. extended must come from emailDeliveredAt
|
||||
// (no email ever delivered -> extended=false -> WELCOME email).
|
||||
let license = store.findLicenseByCustomerId(customerId);
|
||||
assert.equal(Boolean(license.emailDeliveredAt), false,
|
||||
'order A: pre-created license with no delivered email must classify as welcome');
|
||||
|
||||
// simulate the email delivery that invoice.paid triggers:
|
||||
store.updateLicense(license.id, {
|
||||
emailDeliveryStatus: 'delivered',
|
||||
emailDeliveredAt: new Date().toISOString(),
|
||||
lastEmailDeliveryId: `in_${uniq}_1`
|
||||
});
|
||||
|
||||
// RENEWAL (next month's invoice.paid): an email WAS delivered -> extended=true.
|
||||
license = store.findLicenseByCustomerId(customerId);
|
||||
assert.equal(Boolean(license.emailDeliveredAt), true,
|
||||
'after a delivered welcome email, the next invoice must classify as renewal/extended');
|
||||
});
|
||||
|
||||
// The server must call the shared classifier, not inline its own logic.
|
||||
// (Behavioral coverage of the classifier itself lives in license-classify.test.js.)
|
||||
test('server.js delegates classification to classifySubscriptionEmail', () => {
|
||||
const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
|
||||
assert.ok(src.includes('classifySubscriptionEmail(license).extended'),
|
||||
'handler must use the shared classifier');
|
||||
assert.ok(!src.includes('const extended = !license.isNewLicense'),
|
||||
'old row-existence classification must be gone');
|
||||
assert.ok(!src.includes('const extended = Boolean(license.emailDeliveredAt)'),
|
||||
'no inline duplicate of the logic');
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
// Sandbox BEFORE importing store.js — never bind the test to production data.
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-license-welcome-test-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
const store = await import('../src/store.js');
|
||||
const licenseLogic = await import('../src/licenseLogic.js');
|
||||
|
||||
// Regression guard for the welcome-vs-extended email split:
|
||||
// a BRAND-NEW subscription license must report isNewLicense=true (drives the
|
||||
// "Welcome to DashCaddy Premium" email), and a second event on the same
|
||||
// subscription must report isNewLicense=false (drives the "extended" email).
|
||||
test('createOrUpdateLicenseBySubscription flags new vs renewal', () => {
|
||||
const uniq = `welcometest_${Date.now()}`;
|
||||
const customerId = `cus_${uniq}`;
|
||||
const subscriptionId = `sub_${uniq}`;
|
||||
|
||||
const first = store.createOrUpdateLicenseBySubscription(subscriptionId, {
|
||||
subscriptionId,
|
||||
customerId,
|
||||
customerEmail: `${uniq}@example.com`,
|
||||
planCode: 'premium_30d',
|
||||
durationDays: 30,
|
||||
status: 'active',
|
||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
||||
premiumFeatures: { sso: true, recipes: true, swarm: true },
|
||||
deactivatedAt: null,
|
||||
lastStripeEventCreated: 1,
|
||||
lastStripeEventId: `evt_${uniq}_1`
|
||||
});
|
||||
assert.equal(first.isNewLicense, true, 'first sync must be flagged as a NEW license');
|
||||
|
||||
const second = store.createOrUpdateLicenseBySubscription(subscriptionId, {
|
||||
subscriptionId,
|
||||
customerId,
|
||||
customerEmail: `${uniq}@example.com`,
|
||||
planCode: 'premium_30d',
|
||||
durationDays: 30,
|
||||
status: 'active',
|
||||
expiresAt: new Date(Date.now() + 60 * 86400000).toISOString(),
|
||||
premiumFeatures: { sso: true, recipes: true, swarm: true },
|
||||
deactivatedAt: null,
|
||||
lastStripeEventCreated: 2,
|
||||
lastStripeEventId: `evt_${uniq}_2`
|
||||
});
|
||||
assert.equal(second.id, first.id, 'renewal must reuse the same license row');
|
||||
assert.equal(second.key, first.key, 'renewal must keep the same license key');
|
||||
assert.equal(second.isNewLicense, false, 'renewal sync must NOT be flagged as new');
|
||||
|
||||
// server.js wiring: extended = !isNewLicense
|
||||
assert.equal(!second.isNewLicense, true, 'renewal must produce extended=true');
|
||||
assert.equal(!first.isNewLicense, false, 'first purchase must produce extended=false');
|
||||
|
||||
// cleanup: deactivate so the test row is inert in the sandbox store
|
||||
store.updateLicense(first.id, { active: false, deactivatedAt: new Date().toISOString() });
|
||||
});
|
||||
Reference in new issue
Block a user