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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 10:06:07 -05:00
parent 89d06eeb7d
commit c60fa958a5
2 changed files with 36 additions and 10 deletions

View File

@ -83,6 +83,10 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
## QA ## 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 (C1C4) + the functions v2 deploy ### From R29 games review (2026-07-07) — reconciled 2026-07-08 after R30 (C1C4) + the functions v2 deploy
**✅ Shipped since filed (R30 batches C1C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):** **✅ Shipped since filed (R30 batches C1C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**

View File

@ -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 tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
const token = tk.docs[0] && tk.docs[0].data().token // Newest-first: a device that reinstalled registers a fresh token while stale ones linger
if (!token) { // (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)') console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)')
process.exit(3) process.exit(3)
} }
@ -64,6 +72,13 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
const data = { type, couple_id: COUPLE, ...extras } const data = { type, couple_id: COUPLE, ...extras }
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary). // Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
const marker = 'QA-SMOKE:' + type const marker = 'QA-SMOKE:' + type
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({ const res = await admin.messaging().send({
token, token,
notification: { title: 'Closer', body: marker }, notification: { title: 'Closer', body: marker },
@ -72,6 +87,13 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
}) })
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res) console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
process.exit(0) 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) => { })().catch((e) => {
console.error('ERR ' + e.message) console.error('ERR ' + e.message)
process.exit(1) process.exit(1)