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:
parent
89d06eeb7d
commit
c60fa958a5
|
|
@ -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 (C1–C4) + the functions v2 deploy
|
### 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):**
|
**✅ Shipped since filed (R30 batches C1–C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue