From c60fa958a53c8bdcdeb313761ddaef6445e5b1e3 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 10 Jul 2026 10:06:07 -0500 Subject: [PATCH] fix(qa): qa_push.js picks newest FCM token, not docs[0] After an APK reinstall a device registers a fresh FCM token alongside stale ones (~33h). The smoke harness grabbed docs[0] (unordered) and could hit a stale token -> FCM 'Requested entity was not found' -> false smoke FAIL. Now sort fcmTokens by updatedAt and try newest-first across all tokens, skipping dead ones, mirroring the app's sendToUser + pruneTokens behavior. Also file two R31 QA-env observations to Future.md (add the RevenueCat test_ key to local.properties so QA can drive the live paywall; FCM token churn note). Co-Authored-By: Claude Fable 5 --- Future.md | 4 ++++ qa/qa_push.js | 42 ++++++++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Future.md b/Future.md index 5d528688..ddf03ab8 100644 --- a/Future.md +++ b/Future.md @@ -83,6 +83,10 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_ ## QA +### From R31 (2026-07-09) — QA-environment observations (not app defects) +- **Add the RevenueCat `test_` SDK key to `local.properties` on the QA emulators.** The paywall currently can't load real offerings there (`RC_API_KEY` absent → `InvalidCredentialsError` → graceful "Couldn't load plans / Try again"). Now that RevenueCat is configured (Test Store + `closer_premium` entitlement, corrected this cycle), seeding the `test_…` key would let QA drive the *live* paywall (plan pills, purchase flow via the Test Store) instead of only the gate + the error state. The gate itself (free → Paywall) is verified; this unlocks the money-adjacent surface short of a real device. +- Minor: reinstalling the debug APK rotates the FCM token; the app re-registers a fresh one on foreground but stale tokens linger for ~33h until `pruneTokens` clears them on a real send. `qa/qa_push.js` now selects the newest token (was `docs[0]`), so the entrypoint smoke no longer false-FAILs after a reinstall. + ### From R29 games review (2026-07-07) — reconciled 2026-07-08 after R30 (C1–C4) + the functions v2 deploy **✅ Shipped since filed (R30 batches C1–C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):** diff --git a/qa/qa_push.js b/qa/qa_push.js index 6c4fea9b..952e5b3c 100644 --- a/qa/qa_push.js +++ b/qa/qa_push.js @@ -55,8 +55,16 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity' } const tk = await db.collection('users').doc(uid).collection('fcmTokens').get() - const token = tk.docs[0] && tk.docs[0].data().token - if (!token) { + // Newest-first: a device that reinstalled registers a fresh token while stale ones linger + // (FCM rejects those with "Requested entity was not found"). The real Cloud Functions send to + // ALL tokens and prune the dead; here we just try newest-first until one delivers, so a stale + // leftover can't produce a false FAIL. (See sendToUser + pruneTokens in functions/.) + const ms = (v) => (v && v.toMillis ? v.toMillis() : typeof v === 'number' ? v : 0) + const tokens = tk.docs + .map((d) => ({ token: d.data().token, at: ms(d.data().updatedAt) })) + .filter((t) => t.token) + .sort((a, b) => b.at - a.at) + if (tokens.length === 0) { console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)') process.exit(3) } @@ -64,14 +72,28 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity' const data = { type, couple_id: COUPLE, ...extras } // Distinct body so the smoke can find + tap exactly this notification (not a grouped summary). const marker = 'QA-SMOKE:' + type - const res = await admin.messaging().send({ - token, - notification: { title: 'Closer', body: marker }, - android: { priority: 'high', notification: { channelId } }, - data, - }) - console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res) - process.exit(0) + const isDeadToken = (e) => + e && (e.code === 'messaging/registration-token-not-registered' || + e.code === 'messaging/invalid-registration-token' || + /entity was not found|not.?registered/i.test(e.message || '')) + let lastErr + for (const { token } of tokens) { + try { + const res = await admin.messaging().send({ + token, + notification: { title: 'Closer', body: marker }, + android: { priority: 'high', notification: { channelId } }, + data, + }) + console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res) + process.exit(0) + } catch (e) { + lastErr = e + if (isDeadToken(e)) continue // stale token — try the next-newest + throw e + } + } + throw lastErr || new Error('all tokens failed') })().catch((e) => { console.error('ERR ' + e.message) process.exit(1)