backend-manager 5.2.19 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CLAUDE.md +5 -4
  3. package/docs/admin-post-route.md +2 -0
  4. package/docs/ai-library.md +29 -2
  5. package/docs/common-mistakes.md +1 -1
  6. package/docs/environment-detection.md +87 -3
  7. package/docs/marketing-campaigns.md +1 -1
  8. package/docs/payment-system.md +1 -1
  9. package/docs/testing.md +60 -15
  10. package/package.json +1 -1
  11. package/src/cli/commands/install.js +2 -2
  12. package/src/cli/commands/setup.js +12 -4
  13. package/src/cli/commands/test.js +1 -1
  14. package/src/cli/index.js +6 -2
  15. package/src/defaults/CLAUDE.md +2 -2
  16. package/src/defaults/test/_init.js +14 -0
  17. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  18. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  21. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  22. package/src/manager/helpers/analytics.js +3 -1
  23. package/src/manager/helpers/assistant.js +43 -38
  24. package/src/manager/index.js +76 -12
  25. package/src/manager/libraries/ai/index.js +33 -0
  26. package/src/manager/libraries/ai/providers/openai.js +105 -8
  27. package/src/manager/libraries/content/ghostii.js +76 -16
  28. package/src/manager/libraries/email/data/disposable-domains.json +23 -0
  29. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  30. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  31. package/src/manager/libraries/payment/discount-codes.js +1 -0
  32. package/src/manager/routes/admin/post/put.js +1 -1
  33. package/src/manager/routes/handler/post/post.js +1 -1
  34. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  35. package/src/manager/routes/user/delete.js +1 -1
  36. package/src/manager/routes/user/signup/post.js +4 -2
  37. package/src/test/runner.js +142 -20
  38. package/src/test/test-accounts.js +159 -102
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  40. package/test/events/payments/journey-payments-cancel.js +6 -3
  41. package/test/events/payments/journey-payments-failure.js +6 -3
  42. package/test/events/payments/journey-payments-plan-change.js +6 -3
  43. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  44. package/test/events/payments/journey-payments-suspend.js +6 -3
  45. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  46. package/test/events/payments/journey-payments-trial.js +10 -4
  47. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  48. package/test/events/payments/journey-payments-upgrade.js +6 -3
  49. package/test/helpers/content/ghostii-blocks.js +134 -0
  50. package/test/helpers/environment.js +230 -0
  51. package/test/routes/marketing/webhook-forward.js +14 -7
  52. package/test/routes/payments/cancel.js +4 -1
  53. package/test/routes/payments/dispute-alert.js +9 -6
  54. package/test/routes/payments/intent.js +55 -14
  55. package/test/routes/payments/portal.js +4 -1
  56. package/test/routes/payments/refund.js +4 -1
@@ -525,14 +525,19 @@ const TEST_ACCOUNTS = {
525
525
  * Get all test account definitions with resolved emails and dynamic product IDs
526
526
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
527
527
  * @param {object} [config] - BEM config (used to resolve first paid product)
528
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js,
529
+ * keyed by id, each `{ id, uid, email, properties }`. Merged after the built-in
530
+ * accounts; a project account may override a built-in one by reusing its key.
528
531
  * @returns {object} Account definitions with resolved emails
529
532
  */
530
- function getAccountDefinitions(domain, config) {
533
+ function getAccountDefinitions(domain, config, extraAccounts) {
531
534
  const paidProduct = getFirstPaidProduct(config);
532
535
  const accounts = {};
533
536
 
534
- for (const [key, account] of Object.entries(TEST_ACCOUNTS)) {
535
- const properties = JSON.parse(JSON.stringify(account.properties));
537
+ const all = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
538
+
539
+ for (const [key, account] of Object.entries(all)) {
540
+ const properties = JSON.parse(JSON.stringify(account.properties || {}));
536
541
 
537
542
  // Replace hardcoded 'premium' product with the actual first paid product from config
538
543
  if (properties.subscription?.product?.id === 'premium') {
@@ -543,7 +548,7 @@ function getAccountDefinitions(domain, config) {
543
548
  accounts[key] = {
544
549
  id: account.id,
545
550
  uid: account.uid,
546
- email: account.email.replace('{domain}', domain),
551
+ email: (account.email || '').replace('{domain}', domain),
547
552
  properties,
548
553
  };
549
554
  }
@@ -556,10 +561,11 @@ function getAccountDefinitions(domain, config) {
556
561
  * @param {object} admin - Firebase admin instance
557
562
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
558
563
  * @param {object} [config] - BEM config (used to resolve first paid product)
564
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
559
565
  * @returns {Promise<object>} Account credentials with privateKeys
560
566
  */
561
- async function fetchPrivateKeys(admin, domain, config) {
562
- const definitions = getAccountDefinitions(domain, config);
567
+ async function fetchPrivateKeys(admin, domain, config, extraAccounts) {
568
+ const definitions = getAccountDefinitions(domain, config, extraAccounts);
563
569
  const accounts = {};
564
570
 
565
571
  // Fetch all in parallel
@@ -626,50 +632,165 @@ async function fetchPrivateKeys(admin, domain, config) {
626
632
  async function createAccount(admin, account) {
627
633
  const userRef = admin.firestore().doc(`users/${account.uid}`);
628
634
 
629
- // Create Firebase Auth user - triggers auth:on-create
630
- await admin.auth().createUser({
631
- uid: account.uid,
632
- email: account.email,
633
- password: uuid.v4(),
634
- emailVerified: true,
635
- });
635
+ // The Auth user for this UID was just deleted (deleteTestUsers). Its auth:on-delete
636
+ // trigger deletes the Firestore doc ASYNCHRONOUSLY and the emulator does NOT guarantee
637
+ // it fires (or finishes) before our subsequent createUser()'s auth:on-create. A stale
638
+ // on-delete can therefore land AFTER on-create and silently wipe the freshly-written
639
+ // doc — leaving the account with no api.clientId/privateKey. That intermittent clobber
640
+ // is what made the account-structure validation (and every downstream auth/payment test)
641
+ // flaky. We defend with a verify-and-repair retry: create → wait for the on-create write
642
+ // to be COMPLETE (api keys present, not just metadata.tag) → merge props → re-verify the
643
+ // keys survived. If a late on-delete clobbered the doc, recreate from scratch.
644
+ const maxAttempts = 3;
645
+
646
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
647
+ // Create Firebase Auth user - triggers auth:on-create
648
+ await admin.auth().createUser({
649
+ uid: account.uid,
650
+ email: account.email,
651
+ password: uuid.v4(),
652
+ emailVerified: true,
653
+ }).catch(async (e) => {
654
+ // A retry may find the Auth user already present (its doc was clobbered, not the
655
+ // user). Delete it first so the fresh createUser re-fires a clean on-create.
656
+ if (e.code === 'auth/uid-already-exists') {
657
+ await admin.auth().deleteUser(account.uid).catch(() => {});
658
+ await waitForDocGone(userRef);
659
+ await admin.auth().createUser({
660
+ uid: account.uid,
661
+ email: account.email,
662
+ password: uuid.v4(),
663
+ emailVerified: true,
664
+ });
665
+ } else {
666
+ throw e;
667
+ }
668
+ });
669
+
670
+ // Wait for auth:on-create to COMPLETE. Poll on the api keys themselves — the fields the
671
+ // tests actually require — not just metadata.tag, which on its own doesn't prove the
672
+ // doc wasn't subsequently clobbered.
673
+ const ready = await waitForAccountReady(userRef);
674
+
675
+ // Merge test-specific properties (roles, subscription, etc.)
676
+ await userRef.set(account.properties, { merge: true });
677
+
678
+ // Re-verify after the merge: a late on-delete could have struck between the poll and
679
+ // here. If the api keys survived, the account is good. Otherwise loop and recreate.
680
+ const finalDoc = await userRef.get();
681
+ const data = finalDoc.data() || {};
682
+ if (ready && data.api?.clientId && data.api?.privateKey) {
683
+ return { uid: account.uid, email: account.email };
684
+ }
685
+
686
+ // Clobbered (or never completed). Tear down the Auth user so the next attempt starts
687
+ // from a clean slate, then retry.
688
+ if (attempt < maxAttempts) {
689
+ await admin.auth().deleteUser(account.uid).catch(() => {});
690
+ await waitForDocGone(userRef);
691
+ }
692
+ }
693
+
694
+ // Exhausted retries — return anyway so the runner reports the downstream failure with a
695
+ // meaningful test assertion rather than a setup throw.
696
+ return { uid: account.uid, email: account.email };
697
+ }
636
698
 
637
- // Wait for auth:on-create to COMPLETE
638
- // We check for metadata.tag which is set at the END of on-create
699
+ /**
700
+ * Poll until a user doc reflects a COMPLETE auth:on-create write (api keys present).
701
+ * Returns true if it became ready within the window, false on timeout.
702
+ */
703
+ async function waitForAccountReady(userRef) {
639
704
  const maxWait = 15000;
640
705
  const pollInterval = 500;
641
706
  let waited = 0;
642
707
 
643
708
  while (waited < maxWait) {
644
709
  const doc = await userRef.get();
645
- if (doc.exists && doc.data()?.metadata?.tag === 'auth:on-create') {
646
- break;
710
+ const data = doc.exists ? doc.data() : null;
711
+ if (
712
+ data?.metadata?.tag === 'auth:on-create'
713
+ && data.api?.clientId
714
+ && data.api?.privateKey
715
+ ) {
716
+ return true;
647
717
  }
648
718
  await new Promise(resolve => setTimeout(resolve, pollInterval));
649
719
  waited += pollInterval;
650
720
  }
651
721
 
652
- // Merge test-specific properties (roles, subscription, etc.)
653
- await userRef.set(account.properties, { merge: true });
722
+ return false;
723
+ }
654
724
 
655
- return { uid: account.uid, email: account.email };
725
+ /**
726
+ * Poll until a user doc no longer exists (on-delete settled). Bounded; best-effort.
727
+ */
728
+ async function waitForDocGone(userRef) {
729
+ const maxWait = 10000;
730
+ const pollInterval = 200;
731
+ let waited = 0;
732
+
733
+ while (waited < maxWait) {
734
+ const doc = await userRef.get();
735
+ if (!doc.exists) {
736
+ return;
737
+ }
738
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
739
+ waited += pollInterval;
740
+ }
741
+
742
+ // Fallback: force-delete the lingering doc so the next create starts clean.
743
+ await userRef.delete().catch(() => {});
744
+ }
745
+
746
+ /**
747
+ * Flush the ENTIRE emulator Firestore — every top-level collection, recursively.
748
+ *
749
+ * SAFETY: this is destructive, so it ONLY runs when connected to the Firestore
750
+ * emulator (`FIRESTORE_EMULATOR_HOST` is set — which the test command always
751
+ * sets). If that env var is absent, this is a no-op, so it can never wipe a real
752
+ * project's data. The emulator DB is entirely test data, so a full flush is the
753
+ * simplest correct "clean slate" — no per-collection allowlist to maintain.
754
+ *
755
+ * @param {object} admin - Firebase admin instance
756
+ */
757
+ async function flushEmulatorFirestore(admin) {
758
+ if (!process.env.FIRESTORE_EMULATOR_HOST) {
759
+ // Not pointed at the emulator — refuse to mass-delete. No-op.
760
+ return;
761
+ }
762
+
763
+ const firestore = admin.firestore();
764
+ const collections = await firestore.listCollections().catch(() => []);
765
+
766
+ await Promise.all(
767
+ collections.map((collectionRef) => firestore.recursiveDelete(collectionRef).catch(() => {}))
768
+ );
656
769
  }
657
770
 
658
771
  /**
659
772
  * Delete all test users (both Auth and Firestore)
660
- * Uses TEST_ACCOUNTS as the source of truth for which UIDs to delete
661
- * Deleting Auth users triggers on-delete which handles Firestore doc + count decrement
662
- * Waits for Firestore docs to be deleted before returning to ensure clean state
663
- * Called before test runs to ensure clean state
773
+ * Uses TEST_ACCOUNTS (+ any project-defined accounts) as the source of truth for
774
+ * which UIDs to delete. Deleting Auth users triggers on-delete which handles
775
+ * Firestore doc + count decrement.
776
+ * Called before test runs to ensure a clean slate. Flushes the ENTIRE emulator
777
+ * Firestore (the emulator DB is 100% test data — there's nothing to preserve),
778
+ * then deletes the Auth test users. `test/_init.js`'s `setup()` reseeds fixtures
779
+ * afterward. Waits for Firestore docs to be deleted before returning.
664
780
  * @param {object} admin - Firebase admin instance
781
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
665
782
  * @returns {Promise<object>} Result with deleted count
666
783
  */
667
- async function deleteTestUsers(admin) {
784
+ async function deleteTestUsers(admin, extraAccounts) {
668
785
  const results = { deleted: [], skipped: [], failed: [] };
669
786
 
670
- // Delete all known test accounts in parallel
787
+ // Wipe the entire emulator Firestore up front (guarded to emulator-only).
788
+ await flushEmulatorFirestore(admin);
789
+
790
+ // Delete all known test accounts in parallel (built-in + project-defined).
791
+ const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
671
792
  await Promise.all(
672
- Object.values(TEST_ACCOUNTS).map(async (account) => {
793
+ Object.values(allAccounts).map(async (account) => {
673
794
  try {
674
795
  // Delete Firebase Auth user (triggers on-delete which handles Firestore doc + count)
675
796
  await admin.auth().deleteUser(account.uid);
@@ -708,81 +829,16 @@ async function deleteTestUsers(admin) {
708
829
  })
709
830
  );
710
831
 
711
- // Clean up payment-related collections for test accounts.
712
- // Two passes per collection:
713
- // 1. owner-keyed: query by owner test uids (Firestore `in` caps at 30; batch).
714
- // 2. id-keyed: delete any doc whose id starts with the `_test-` prefix.
715
- // Pass 2 catches docs that have no owner field (e.g. dispute alerts, raw test webhooks).
716
- // All test-fixture IDs MUST start with `_test-` — that's the cleanup contract.
717
- const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
718
- // Collections that may carry test data tied to a test user (owner-keyed) or
719
- // identified solely by an `_test-` doc id prefix (id-keyed). All must be wiped
720
- // at the start of every run so a test that died mid-execution leaves no
721
- // ghosts. New collections that participate in tests MUST be added here too.
722
- const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes'];
723
- // Collections that exist solely for tests — wipe in full. All docs in these
724
- // collections come from tests, so a single recursive delete handles cleanup.
725
- const testOnlyCollections = ['_test', '_test_query'];
726
- const UID_BATCH_SIZE = 30;
727
- const TEST_ID_PREFIX = '_test-';
728
-
729
- const uidBatches = [];
730
- for (let i = 0; i < testUids.length; i += UID_BATCH_SIZE) {
731
- uidBatches.push(testUids.slice(i, i + UID_BATCH_SIZE));
832
+ // Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
833
+ // flush already ran in flushEmulatorFirestore() at the start of this function.)
834
+ // `admin.database()` throws synchronously when no Database URL is configured,
835
+ // so guard the whole thing RTDB is optional for a project.
836
+ try {
837
+ await admin.database().ref('_test').remove();
838
+ } catch (e) {
839
+ // RTDB not configured / no database URL ignore.
732
840
  }
733
841
 
734
- await Promise.all([
735
- // Mixed collections: scoped delete of test data only.
736
- ...testDataCollections.map(async (collection) => {
737
- // Pass 1 — owner-keyed
738
- for (const batch of uidBatches) {
739
- try {
740
- const snapshot = await admin.firestore().collection(collection)
741
- .where('owner', 'in', batch)
742
- .get();
743
-
744
- await Promise.all(
745
- snapshot.docs.map(doc => doc.ref.delete())
746
- );
747
- } catch (e) {
748
- // Collection may not exist yet, or doesn't carry an owner field — ignore
749
- }
750
- }
751
-
752
- // Pass 2 — id-keyed (catches ownerless test docs)
753
- // documentId() range scan: `_test-` ≤ id < `_test.` (the next ASCII char after `-` is `.`)
754
- try {
755
- const snapshot = await admin.firestore().collection(collection)
756
- .where(admin.firestore.FieldPath.documentId(), '>=', TEST_ID_PREFIX)
757
- .where(admin.firestore.FieldPath.documentId(), '<', '_test.')
758
- .get();
759
-
760
- await Promise.all(
761
- snapshot.docs.map(doc => doc.ref.delete())
762
- );
763
- } catch (e) {
764
- // Collection may not exist yet — ignore
765
- }
766
- }),
767
- // Test-only Firestore collections: wipe in full.
768
- ...testOnlyCollections.map(async (collection) => {
769
- try {
770
- const snapshot = await admin.firestore().collection(collection).get();
771
- await Promise.all(snapshot.docs.map(doc => doc.ref.delete()));
772
- } catch (e) {
773
- // Collection may not exist yet — ignore
774
- }
775
- }),
776
- // Realtime Database: wipe the `_test` namespace in full.
777
- (async () => {
778
- try {
779
- await admin.database().ref('_test').remove();
780
- } catch (e) {
781
- // RTDB may not be configured for this project — ignore
782
- }
783
- })(),
784
- ]);
785
-
786
842
  return {
787
843
  success: results.failed.length === 0,
788
844
  deleted: results.deleted.length,
@@ -798,10 +854,11 @@ async function deleteTestUsers(admin) {
798
854
  * @param {object} admin - Firebase admin instance
799
855
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
800
856
  * @param {object} [config] - BEM config (used to resolve first paid product)
857
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
801
858
  * @returns {Promise<object>} Result with created/failed counts
802
859
  */
803
- async function createTestAccounts(admin, domain, config) {
804
- const definitions = getAccountDefinitions(domain, config);
860
+ async function createTestAccounts(admin, domain, config, extraAccounts) {
861
+ const definitions = getAccountDefinitions(domain, config, extraAccounts);
805
862
  const results = { created: [], failed: [] };
806
863
 
807
864
  // Create all accounts in parallel
@@ -14,10 +14,14 @@ module.exports = {
14
14
  tests: [
15
15
  {
16
16
  name: 'setup-paid-subscription',
17
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
17
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
18
18
  const uid = accounts['journey-payments-cancel-route'].uid;
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
19
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
20
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
21
25
 
22
26
  state.uid = uid;
23
27
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-cancel'].uid;
18
18
 
19
- // Resolve first paid product from config
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
20
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
22
25
 
23
26
  state.uid = uid;
24
27
  state.paidProductId = paidProduct.id;
@@ -16,12 +16,15 @@ module.exports = {
16
16
  tests: [
17
17
  {
18
18
  name: 'setup-paid-subscription',
19
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
19
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
20
20
  const uid = accounts['journey-payments-failure'].uid;
21
21
 
22
- // Resolve first paid subscription product
22
+ // Resolve first paid subscription product. If the brand has none configured,
23
+ // skip the entire journey — this is a config-gap, not a code failure.
23
24
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
24
- assert.ok(paidProduct, 'Config should have at least one paid subscription product');
25
+ if (!paidProduct) {
26
+ skip('No paid subscription product configured in this brand');
27
+ }
25
28
 
26
29
  state.uid = uid;
27
30
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-plan-change'].uid;
18
18
 
19
- // Resolve two distinct paid subscription products from config
19
+ // Resolve two distinct paid subscription products from config. If the brand
20
+ // has fewer than two, skip the journey — this is a config-gap, not a code failure.
20
21
  const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
21
- assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
22
+ if (paidProducts.length < 2) {
23
+ skip('Fewer than two paid subscription products configured in this brand');
24
+ }
22
25
 
23
26
  const productA = paidProducts[0];
24
27
  const productB = paidProducts[1];
@@ -18,11 +18,15 @@ module.exports = {
18
18
  tests: [
19
19
  {
20
20
  name: 'setup-paid-subscription',
21
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
21
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
22
22
  const uid = accounts['journey-payments-refund-webhook'].uid;
23
23
 
24
+ // Resolve a paid product with a monthly price. If the brand has none configured,
25
+ // skip the entire journey — this is a config-gap, not a code failure.
24
26
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
25
- assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
27
+ if (!paidProduct) {
28
+ skip('No paid product with monthly price configured in this brand');
29
+ }
26
30
 
27
31
  state.uid = uid;
28
32
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-suspend'].uid;
18
18
 
19
- // Resolve first paid product from config
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
20
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
22
25
 
23
26
  state.uid = uid;
24
27
  state.paidProductId = paidProduct.id;
@@ -16,7 +16,7 @@ module.exports = {
16
16
  tests: [
17
17
  {
18
18
  name: 'verify-starts-as-basic',
19
- async run({ accounts, firestore, assert, state, config }) {
19
+ async run({ accounts, firestore, assert, state, config, skip }) {
20
20
  const uid = accounts['journey-payments-trial-cancel'].uid;
21
21
  const userDoc = await firestore.get(`users/${uid}`);
22
22
 
@@ -24,9 +24,12 @@ module.exports = {
24
24
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
25
25
  assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
26
26
 
27
- // Resolve first paid product with trial from config
27
+ // Resolve first paid product with trial from config. If the brand has none
28
+ // configured, skip the entire journey — this is a config-gap, not a code failure.
28
29
  const trialProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices && p.trial?.days);
29
- assert.ok(trialProduct, 'Config should have at least one paid product with trial');
30
+ if (!trialProduct) {
31
+ skip('No paid product with trial configured in this brand');
32
+ }
30
33
 
31
34
  state.uid = uid;
32
35
  state.paidProductId = trialProduct.id;
@@ -13,7 +13,7 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'verify-starts-as-basic',
16
- async run({ accounts, firestore, assert, state, config }) {
16
+ async run({ accounts, firestore, assert, state, config, skip }) {
17
17
  const uid = accounts['journey-payments-trial'].uid;
18
18
  const userDoc = await firestore.get(`users/${uid}`);
19
19
 
@@ -21,9 +21,15 @@ module.exports = {
21
21
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
22
22
  assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
23
23
 
24
- // Resolve first paid product from config
25
- const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
26
- assert.ok(paidProduct, 'Config should have at least one paid product');
24
+ // Resolve first paid product WITH a trial from config. The test processor
25
+ // only creates a trialing subscription when product.trial.days > 0, so a
26
+ // brand whose paid products all have trial.days: 0 (e.g. trials disabled)
27
+ // has nothing to exercise here — skip the journey. This is a config-gap,
28
+ // not a code failure, and mirrors journey-payments-trial-cancel.js.
29
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices && p.trial?.days);
30
+ if (!paidProduct) {
31
+ skip('No paid product with trial configured in this brand');
32
+ }
27
33
 
28
34
  state.uid = uid;
29
35
  state.paidProductId = paidProduct.id;
@@ -21,11 +21,15 @@ module.exports = {
21
21
  tests: [
22
22
  {
23
23
  name: 'setup-paid-subscription',
24
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
24
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
25
25
  const uid = accounts['journey-payments-uid-resolution'].uid;
26
26
 
27
+ // Resolve a paid product with a monthly price. If the brand has none configured,
28
+ // skip the entire journey — this is a config-gap, not a code failure.
27
29
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
28
- assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
30
+ if (!paidProduct) {
31
+ skip('No paid product with monthly price configured in this brand');
32
+ }
29
33
 
30
34
  state.uid = uid;
31
35
  state.paidProductId = paidProduct.id;
@@ -13,7 +13,7 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'verify-starts-as-basic',
16
- async run({ accounts, firestore, assert, state, config }) {
16
+ async run({ accounts, firestore, assert, state, config, skip }) {
17
17
  const uid = accounts['journey-payments-upgrade'].uid;
18
18
  const userDoc = await firestore.get(`users/${uid}`);
19
19
 
@@ -21,9 +21,12 @@ module.exports = {
21
21
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
22
22
  assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
23
23
 
24
- // Resolve first paid product from config
24
+ // Resolve first paid product from config. If the brand has none configured,
25
+ // skip the entire journey — this is a config-gap, not a code failure.
25
26
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
26
- assert.ok(paidProduct, 'Config should have at least one paid product');
27
+ if (!paidProduct) {
28
+ skip('No paid product configured in this brand');
29
+ }
27
30
 
28
31
  state.uid = uid;
29
32
  state.paidProductId = paidProduct.id;