From bb3e8fb8c4b42de7d3c9c78a2bb1905e9aafbcb8 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 10 Oct 2026 03:18:47 -0400 Subject: [PATCH] feat(alerts): authoritative cross-device road alert vote aggregation via Firestore transactions --- .../emulator-tests/firestore.rules.test.js | 322 +++++++++++++++++- firestore.rules | 62 +++- .../alerts/swarm_road_alert_service.dart | 180 +++++++--- test/swarm_road_alert_test.dart | 55 ++- 4 files changed, 570 insertions(+), 49 deletions(-) diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index f7e7160..5f33515 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -4,7 +4,7 @@ import { assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; -import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore'; +import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, runTransaction, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; @@ -6807,4 +6807,324 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => { source: 's'.repeat(65), })); }); + + describe('Authoritative Cross-Device Vote Aggregation via Transactions', () => { + test('votes from two devices produce consistent totals (using runTransaction)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + + const alertId = 'alert_cross_device_totals'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + status: 'active', + confirmations: 1, + dismissals: 0, + reportedAt: Date.now() - 1000, + expiresAt: Date.now() + 3600000, + })); + + // Bob (Device 1) runs transaction: confirms alert -> alert has confirmations: 2, status: 'confirmed' + await assertSucceeds(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextConf = snap.data().confirmations + 1; + const nextStatus = nextConf >= 2 ? 'confirmed' : 'active'; + tx.update(alertRef, { + confirmations: nextConf, + status: nextStatus, + }); + tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { + voterUid: 'bob', + type: 'confirm', + votedAt: Date.now(), + }); + })); + + const bobAlertSnap = await getDoc(doc(bob, `road_alerts/${alertId}`)); + expect(bobAlertSnap.data().confirmations).toBe(2); + expect(bobAlertSnap.data().status).toBe('confirmed'); + + // Charlie (Device 2) runs transaction: confirms alert -> alert has confirmations: 3, status: 'confirmed' + await assertSucceeds(runTransaction(charlie, async (tx) => { + const alertRef = doc(charlie, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextConf = snap.data().confirmations + 1; + const nextStatus = nextConf >= 2 ? 'confirmed' : 'active'; + tx.update(alertRef, { + confirmations: nextConf, + status: nextStatus, + }); + tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), { + voterUid: 'charlie', + type: 'confirm', + votedAt: Date.now(), + }); + })); + + // Dave (Device 3) reads alert -> sees confirmations: 3, status: 'confirmed' + const daveAlertSnap = await getDoc(doc(dave, `road_alerts/${alertId}`)); + expect(daveAlertSnap.data().confirmations).toBe(3); + expect(daveAlertSnap.data().status).toBe('confirmed'); + }); + + test('repeated voting cannot increase totals', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + const alertId = 'alert_no_repeated_votes'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + status: 'active', + confirmations: 1, + dismissals: 0, + reportedAt: Date.now() - 1000, + expiresAt: Date.now() + 3600000, + })); + + // Bob votes once via transaction -> succeeds + await assertSucceeds(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextConf = snap.data().confirmations + 1; + tx.update(alertRef, { + confirmations: nextConf, + status: nextConf >= 2 ? 'confirmed' : 'active', + }); + tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { + voterUid: 'bob', + type: 'confirm', + votedAt: Date.now(), + }); + })); + + // Bob attempts a second transaction on the same alert -> runTransaction fails with PERMISSION_DENIED + await assertFails(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextConf = snap.data().confirmations + 1; + tx.update(alertRef, { + confirmations: nextConf, + status: 'confirmed', + }); + tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { + voterUid: 'bob', + type: 'confirm', + votedAt: Date.now(), + }); + })); + + // Alert remains at confirmations: 2 + const snapAfter = await getDoc(doc(alice, `road_alerts/${alertId}`)); + expect(snapAfter.data().confirmations).toBe(2); + }); + + test('concurrent votes are counted correctly', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + const alertId = 'alert_concurrent_tx_votes'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + status: 'active', + confirmations: 1, + dismissals: 0, + reportedAt: Date.now() - 1000, + expiresAt: Date.now() + 3600000, + })); + + const runTx = async (client, uid) => { + for (let attempt = 0; attempt < 5; attempt++) { + try { + await runTransaction(client, async (tx) => { + const alertRef = doc(client, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextConf = snap.data().confirmations + 1; + tx.update(alertRef, { + confirmations: nextConf, + status: nextConf >= 2 ? 'confirmed' : 'active', + }); + tx.set(doc(client, `road_alerts/${alertId}/votes/${uid}`), { + voterUid: uid, + type: 'confirm', + votedAt: Date.now(), + }); + }); + return; + } catch (e) { + if (attempt === 4) throw e; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + }; + + // Bob and Charlie submit concurrent transactions via Promise.all + await assertSucceeds(Promise.all([ + runTx(bob, 'bob'), + runTx(charlie, 'charlie'), + ])); + + // Both succeed and alert ends with confirmations: 3 + const finalAlert = await getDoc(doc(alice, `road_alerts/${alertId}`)); + expect(finalAlert.data().confirmations).toBe(3); + expect(finalAlert.data().status).toBe('confirmed'); + }); + + test('dismissal consensus clears an alert across devices', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + + const alertId = 'alert_dismissal_consensus'; + const originalExpiresAt = Date.now() + 3600000; + const reportedAt = Date.now() - 1000; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + status: 'active', + confirmations: 1, + dismissals: 0, + reportedAt, + expiresAt: originalExpiresAt, + })); + + // Bob submits dismissal transaction -> dismissals: 1, status: 'active' + await assertSucceeds(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextDismissals = snap.data().dismissals + 1; + tx.update(alertRef, { + dismissals: nextDismissals, + status: 'active', + }); + tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { + voterUid: 'bob', + type: 'dismiss', + votedAt: Date.now(), + }); + })); + + const alertAfterBob = await getDoc(doc(bob, `road_alerts/${alertId}`)); + expect(alertAfterBob.data().dismissals).toBe(1); + expect(alertAfterBob.data().status).toBe('active'); + + // Charlie submits dismissal transaction -> dismissals: 2, status: 'cleared', expiresAt: reportedAt (or <= alert.expiresAt) + const clearTime = Date.now(); + await assertSucceeds(runTransaction(charlie, async (tx) => { + const alertRef = doc(charlie, `road_alerts/${alertId}`); + const snap = await tx.get(alertRef); + const nextDismissals = snap.data().dismissals + 1; + tx.update(alertRef, { + dismissals: nextDismissals, + status: 'cleared', + expiresAt: clearTime, + }); + tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), { + voterUid: 'charlie', + type: 'dismiss', + votedAt: clearTime, + }); + })); + + // Dave reads alert -> sees status: 'cleared' and dismissals: 2 + const daveAlert = await getDoc(doc(dave, `road_alerts/${alertId}`)); + expect(daveAlert.data().dismissals).toBe(2); + expect(daveAlert.data().status).toBe('cleared'); + expect(daveAlert.data().expiresAt).toBe(clearTime); + }); + + test('expired alerts cannot be revived by a modified client', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + const alertIdExpired = 'alert_revive_expired'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdExpired}`), { + ...baseAlert, + id: alertIdExpired, + status: 'active', + reportedAt: Date.now() - 5000, + expiresAt: Date.now() - 1000, + })); + + // Modified client tries to update expiresAt on expired alert -> fails + await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdExpired}`), { + expiresAt: Date.now() + 100000, + })); + + // Modified client tries transaction vote on expired alert -> fails + await assertFails(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertIdExpired}`); + const snap = await tx.get(alertRef); + tx.update(alertRef, { + confirmations: snap.data().confirmations + 1, + status: 'confirmed', + }); + tx.set(doc(bob, `road_alerts/${alertIdExpired}/votes/bob`), { + voterUid: 'bob', + type: 'confirm', + votedAt: Date.now(), + }); + })); + + // Modified client tries to vote or update a cleared alert -> fails + const alertIdCleared = 'alert_revive_cleared'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdCleared}`), { + ...baseAlert, + id: alertIdCleared, + status: 'cleared', + reportedAt: Date.now() - 1000, + expiresAt: Date.now() + 3600000, + })); + + await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdCleared}`), { + status: 'active', + })); + + await assertFails(runTransaction(bob, async (tx) => { + const alertRef = doc(bob, `road_alerts/${alertIdCleared}`); + const snap = await tx.get(alertRef); + tx.update(alertRef, { + confirmations: snap.data().confirmations + 1, + status: 'confirmed', + }); + tx.set(doc(bob, `road_alerts/${alertIdCleared}/votes/bob`), { + voterUid: 'bob', + type: 'confirm', + votedAt: Date.now(), + }); + })); + }); + + test('direct count manipulation without atomic vote creation remains rejected', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + const alertId = 'alert_direct_count_rejected'; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + status: 'active', + reportedAt: Date.now() - 1000, + expiresAt: Date.now() + 3600000, + })); + + const alertRefBob = doc(bob, `road_alerts/${alertId}`); + + // Direct confirmation count increment without vote doc creation fails + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + })); + + // Direct dismissal count increment without vote doc creation fails + await assertFails(updateDoc(alertRefBob, { + dismissals: 1, + })); + }); + }); }); diff --git a/firestore.rules b/firestore.rules index 45ddf9f..4d2158b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1537,24 +1537,80 @@ service cloud.firestore { && (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']); } + function validAlertUpdate() { + let votePath = /databases/$(database)/documents/road_alerts/$(alertId)/votes/$(request.auth.uid); + let currentStatus = ('status' in resource.data) ? resource.data.status : 'active'; + return signedIn() + && validAlertKeys() + && resource.data.expiresAt > request.time.toMillis() + && currentStatus != 'cleared' + && currentStatus != 'expired' + && resource.data.reporterUid != request.auth.uid + && request.resource.data.id == resource.data.id + && request.resource.data.reporterUid == resource.data.reporterUid + && request.resource.data.category == resource.data.category + && request.resource.data.latitude == resource.data.latitude + && request.resource.data.longitude == resource.data.longitude + && request.resource.data.reportedAt == resource.data.reportedAt + && (!('subtype' in resource.data) || request.resource.data.subtype == resource.data.subtype) + && (!('source' in resource.data) || request.resource.data.source == resource.data.source) + && (!('roadName' in resource.data) || request.resource.data.roadName == resource.data.roadName) + && (!('description' in resource.data) || request.resource.data.description == resource.data.description) + && (!('heading' in resource.data) || request.resource.data.heading == resource.data.heading) + && (!('groupRideId' in resource.data) || request.resource.data.groupRideId == resource.data.groupRideId) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status']) + && !exists(votePath) + && existsAfter(votePath) + && getAfter(votePath).data.voterUid == request.auth.uid + && ( + ( + getAfter(votePath).data.type == 'confirm' + && request.resource.data.confirmations == resource.data.confirmations + 1 + && request.resource.data.dismissals == resource.data.dismissals + && request.resource.data.expiresAt == resource.data.expiresAt + && request.resource.data.status == (request.resource.data.confirmations >= 2 ? 'confirmed' : currentStatus) + ) || ( + getAfter(votePath).data.type == 'dismiss' + && request.resource.data.dismissals == resource.data.dismissals + 1 + && request.resource.data.confirmations == resource.data.confirmations + && ( + (request.resource.data.dismissals >= 2 && request.resource.data.dismissals >= resource.data.confirmations) + ? (request.resource.data.status == 'cleared' + && request.resource.data.expiresAt <= resource.data.expiresAt + && request.resource.data.expiresAt >= resource.data.reportedAt) + : (request.resource.data.status == currentStatus + && request.resource.data.expiresAt == resource.data.expiresAt) + ) + ) + ); + } + allow read: if signedIn(); allow create: if validAlertCreate(); - allow update: if false; // Ordinary clients CANNOT directly mutate alert fields or aggregate counts! + allow update: if validAlertUpdate(); allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid; match /votes/{voterUid} { function validVoteCreate() { let alertDoc = get(/databases/$(database)/documents/road_alerts/$(alertId)); + let alertAfter = getAfter(/databases/$(database)/documents/road_alerts/$(alertId)); + let currentStatus = ('status' in alertDoc.data) ? alertDoc.data.status : 'active'; return signedIn() && request.auth.uid == voterUid && alertDoc != null && alertDoc.data.reporterUid != request.auth.uid && alertDoc.data.expiresAt > request.time.toMillis() - && (!('status' in alertDoc.data) || alertDoc.data.status != 'cleared') + && currentStatus != 'cleared' + && currentStatus != 'expired' && request.resource.data.keys().hasOnly(['voterUid', 'type', 'votedAt']) && request.resource.data.voterUid == voterUid && request.resource.data.type in ['confirm', 'dismiss'] - && (request.resource.data.votedAt is int || request.resource.data.votedAt is timestamp); + && (request.resource.data.votedAt is int || request.resource.data.votedAt is timestamp) + && ( + (request.resource.data.type == 'confirm' && alertAfter.data.confirmations == alertDoc.data.confirmations + 1) + || (request.resource.data.type == 'dismiss' && alertAfter.data.dismissals == alertDoc.data.dismissals + 1) + || (alertAfter.data.confirmations == alertDoc.data.confirmations && alertAfter.data.dismissals == alertDoc.data.dismissals) + ); } allow read: if signedIn(); diff --git a/lib/services/alerts/swarm_road_alert_service.dart b/lib/services/alerts/swarm_road_alert_service.dart index 803ebfa..4e5e37e 100644 --- a/lib/services/alerts/swarm_road_alert_service.dart +++ b/lib/services/alerts/swarm_road_alert_service.dart @@ -285,6 +285,8 @@ class SwarmRoadAlertService { /// Trailing rider confirms an existing alert (increases confidence). /// Enforces per-user vote restrictions (no repeat votes, no self-voting). + /// Uses a Firestore transaction to authoritatively update confirmation count + /// and vote subcollection atomically across all devices. Future confirmAlert(String alertId, {String? voterUid}) async { final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; @@ -305,28 +307,10 @@ class SwarmRoadAlertService { } _userVotes.add(voteKey); - final firestore = _firestore; - if (firestore != null) { - try { - await firestore - .collection('road_alerts') - .doc(alertId) - .collection('votes') - .doc(uid) - .set({ - 'voterUid': uid, - 'type': 'confirm', - 'votedAt': DateTime.now().millisecondsSinceEpoch, - }); - } catch (e) { - if (kDebugMode) { - debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e'); - } - _userVotes.remove(voteKey); - return false; - } - } + final previousCached = cached; + final now = DateTime.now(); + // Optimistically update local cache if (cached != null) { final updatedConfirmations = cached.confirmations + 1; final newStatus = updatedConfirmations >= 2 @@ -338,11 +322,74 @@ class SwarmRoadAlertService { ); } + final firestore = _firestore; + if (firestore != null) { + try { + final alertRef = firestore.collection('road_alerts').doc(alertId); + final voteRef = alertRef.collection('votes').doc(uid); + + await firestore.runTransaction((transaction) async { + final snapshot = await transaction.get(alertRef); + if (!snapshot.exists) { + throw Exception('Alert does not exist'); + } + + final data = snapshot.data(); + if (data == null) { + throw Exception('Alert data is null'); + } + + if (data['reporterUid'] == uid) { + throw Exception('Self-voting is not permitted'); + } + + final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0; + if (expiresAtMillis <= now.millisecondsSinceEpoch) { + throw Exception('Alert has already expired'); + } + + final statusStr = data['status']?.toString(); + if (statusStr == 'cleared' || statusStr == 'expired') { + throw Exception('Alert is already $statusStr'); + } + + final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1; + final updatedConfirmations = currentConfirmations + 1; + final newStatus = updatedConfirmations >= 2 ? 'confirmed' : (statusStr ?? 'active'); + + transaction.set(voteRef, { + 'voterUid': uid, + 'type': 'confirm', + 'votedAt': now.millisecondsSinceEpoch, + }); + + transaction.update(alertRef, { + 'confirmations': updatedConfirmations, + 'status': newStatus, + }); + }); + } catch (e) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e'); + } + // Rollback optimistic cache and vote record on failure + if (previousCached != null) { + _localSwarmCache[alertId] = previousCached; + } else { + _localSwarmCache.remove(alertId); + } + _userVotes.remove(voteKey); + return false; + } + } + return true; } /// Trailing rider reports alert is clear / no longer present. /// Automatically marks alert cleared/expired when dismissals reach threshold. + /// Uses a Firestore transaction to authoritatively update dismissal count + /// and vote subcollection atomically across all devices. Future clearAlert(String alertId, {String? voterUid}) async { final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; @@ -363,30 +410,10 @@ class SwarmRoadAlertService { } _userVotes.add(voteKey); + final previousCached = cached; final now = DateTime.now(); - final firestore = _firestore; - if (firestore != null) { - try { - await firestore - .collection('road_alerts') - .doc(alertId) - .collection('votes') - .doc(uid) - .set({ - 'voterUid': uid, - 'type': 'dismiss', - 'votedAt': now.millisecondsSinceEpoch, - }); - } catch (e) { - if (kDebugMode) { - debugPrint('[SwarmRoadAlertService] Firestore clear error: $e'); - } - _userVotes.remove(voteKey); - return false; - } - } - + // Optimistically update local cache if (cached != null) { final updatedDismissals = cached.dismissals + 1; if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { @@ -401,6 +428,73 @@ class SwarmRoadAlertService { } } + final firestore = _firestore; + if (firestore != null) { + try { + final alertRef = firestore.collection('road_alerts').doc(alertId); + final voteRef = alertRef.collection('votes').doc(uid); + + await firestore.runTransaction((transaction) async { + final snapshot = await transaction.get(alertRef); + if (!snapshot.exists) { + throw Exception('Alert does not exist'); + } + + final data = snapshot.data(); + if (data == null) { + throw Exception('Alert data is null'); + } + + if (data['reporterUid'] == uid) { + throw Exception('Self-clearing is not permitted'); + } + + final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0; + final nowMillis = now.millisecondsSinceEpoch; + if (expiresAtMillis <= nowMillis) { + throw Exception('Alert has already expired'); + } + + final statusStr = data['status']?.toString(); + if (statusStr == 'cleared' || statusStr == 'expired') { + throw Exception('Alert is already $statusStr'); + } + + final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1; + final currentDismissals = (data['dismissals'] as num?)?.toInt() ?? 0; + final updatedDismissals = currentDismissals + 1; + + final isCleared = updatedDismissals >= 2 && updatedDismissals >= currentConfirmations; + final newStatus = isCleared ? 'cleared' : (statusStr ?? 'active'); + final newExpiresAt = isCleared ? nowMillis : expiresAtMillis; + + transaction.set(voteRef, { + 'voterUid': uid, + 'type': 'dismiss', + 'votedAt': nowMillis, + }); + + transaction.update(alertRef, { + 'dismissals': updatedDismissals, + 'status': newStatus, + 'expiresAt': newExpiresAt, + }); + }); + } catch (e) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Firestore clear error: $e'); + } + // Rollback optimistic cache and vote record on failure + if (previousCached != null) { + _localSwarmCache[alertId] = previousCached; + } else { + _localSwarmCache.remove(alertId); + } + _userVotes.remove(voteKey); + return false; + } + } + return true; } diff --git a/test/swarm_road_alert_test.dart b/test/swarm_road_alert_test.dart index 18aaf0a..18ad002 100644 --- a/test/swarm_road_alert_test.dart +++ b/test/swarm_road_alert_test.dart @@ -210,7 +210,7 @@ void main() { expect(expiresAt.difference(reportedAt).inMinutes, 120); }); - test('confirmAlert writes vote to subcollection and increments confirmations in memory', () async { + test('confirmAlert writes vote to subcollection and updates parent confirmations in Firestore and cache', () async { final report = await service.reportAlert( category: RoadAlertCategory.police, latitude: 34.0, @@ -230,9 +230,18 @@ void main() { expect(voteDoc.data()!['type'], 'confirm'); expect(voteDoc.data()!['voterUid'], 'rider_trailing'); expect(service.getCachedReport(report.id)?.confirmations, 2); + expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.confirmed); + + // Verify Firestore parent doc is updated for cross-device visibility + final parentDoc = await fakeFirestore + .collection('road_alerts') + .doc(report.id) + .get(); + expect(parentDoc.data()!['confirmations'], 2); + expect(parentDoc.data()!['status'], 'confirmed'); }); - test('clearAlert writes dismissal vote to subcollection and auto-expires after 2 dismissals', () async { + test('clearAlert writes dismissal vote to subcollection and auto-clears across Firestore and cache', () async { final report = await service.reportAlert( category: RoadAlertCategory.hazard, latitude: 34.0, @@ -251,6 +260,9 @@ void main() { expect(voteA.exists, isTrue); expect(voteA.data()!['type'], 'dismiss'); expect(service.getCachedReport(report.id)?.dismissals, 1); + final parentDoc1 = await fakeFirestore.collection('road_alerts').doc(report.id).get(); + expect(parentDoc1.data()!['dismissals'], 1); + expect(parentDoc1.data()!['status'], 'active'); // 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately final res2 = await service.clearAlert(report.id, voterUid: 'rider_b'); @@ -265,6 +277,45 @@ void main() { expect(voteB.data()!['type'], 'dismiss'); expect(service.getCachedReport(report.id)?.dismissals, 2); expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared); + + // Verify Firestore parent doc is cleared for all devices + final parentDoc2 = await fakeFirestore.collection('road_alerts').doc(report.id).get(); + expect(parentDoc2.data()!['dismissals'], 2); + expect(parentDoc2.data()!['status'], 'cleared'); + }); + + test('failed vote rolls back optimistic cache and vote state', () async { + final past = DateTime.now().subtract(const Duration(hours: 2)); + final expiredReport = SwarmReport( + id: 'swarm_expired_test', + category: RoadAlertCategory.hazard, + latitude: 34.0, + longitude: -118.0, + description: 'Old obstacle', + reporterUid: 'rider_original', + reportedAt: past, + expiresAt: past.add(const Duration(minutes: 30)), + confirmations: 1, + dismissals: 0, + ); + + // Save expired alert to Firestore + await fakeFirestore + .collection('road_alerts') + .doc(expiredReport.id) + .set(expiredReport.toJson()); + + // Attempt to confirm expired alert + final result = await service.confirmAlert(expiredReport.id, voterUid: 'rider_other'); + expect(result, isFalse); + + // Verify vote record was rolled back + expect(service.hasUserVoted(expiredReport.id, 'rider_other'), isFalse); + expect(service.canUserVote(expiredReport.id, 'rider_other'), isTrue); + + // Verify Firestore parent doc was untouched + final doc = await fakeFirestore.collection('road_alerts').doc(expiredReport.id).get(); + expect(doc.data()!['confirmations'], 1); }); test('getAlertsNear finds alerts within radius and excludes distant ones', () async {