feat(alerts): authoritative cross-device road alert vote aggregation via Firestore transactions

This commit is contained in:
Developer 2026-10-10 03:18:47 -04:00
parent de94bbb3e3
commit bb3e8fb8c4
4 changed files with 570 additions and 49 deletions

View File

@ -4,7 +4,7 @@ import {
assertSucceeds, assertSucceeds,
initializeTestEnvironment, initializeTestEnvironment,
} from '@firebase/rules-unit-testing'; } 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -6807,4 +6807,324 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
source: 's'.repeat(65), 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,
}));
});
});
}); });

View File

@ -1537,24 +1537,80 @@ service cloud.firestore {
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']); && (!('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 read: if signedIn();
allow create: if validAlertCreate(); 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; allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid;
match /votes/{voterUid} { match /votes/{voterUid} {
function validVoteCreate() { function validVoteCreate() {
let alertDoc = get(/databases/$(database)/documents/road_alerts/$(alertId)); 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() return signedIn()
&& request.auth.uid == voterUid && request.auth.uid == voterUid
&& alertDoc != null && alertDoc != null
&& alertDoc.data.reporterUid != request.auth.uid && alertDoc.data.reporterUid != request.auth.uid
&& alertDoc.data.expiresAt > request.time.toMillis() && 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.keys().hasOnly(['voterUid', 'type', 'votedAt'])
&& request.resource.data.voterUid == voterUid && request.resource.data.voterUid == voterUid
&& request.resource.data.type in ['confirm', 'dismiss'] && 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(); allow read: if signedIn();

View File

@ -285,6 +285,8 @@ class SwarmRoadAlertService {
/// Trailing rider confirms an existing alert (increases confidence). /// Trailing rider confirms an existing alert (increases confidence).
/// Enforces per-user vote restrictions (no repeat votes, no self-voting). /// 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<bool> confirmAlert(String alertId, {String? voterUid}) async { Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider'; final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId]; final cached = _localSwarmCache[alertId];
@ -305,28 +307,10 @@ class SwarmRoadAlertService {
} }
_userVotes.add(voteKey); _userVotes.add(voteKey);
final firestore = _firestore; final previousCached = cached;
if (firestore != null) { final now = DateTime.now();
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;
}
}
// Optimistically update local cache
if (cached != null) { if (cached != null) {
final updatedConfirmations = cached.confirmations + 1; final updatedConfirmations = cached.confirmations + 1;
final newStatus = updatedConfirmations >= 2 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; return true;
} }
/// Trailing rider reports alert is clear / no longer present. /// Trailing rider reports alert is clear / no longer present.
/// Automatically marks alert cleared/expired when dismissals reach threshold. /// 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<bool> clearAlert(String alertId, {String? voterUid}) async { Future<bool> clearAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider'; final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId]; final cached = _localSwarmCache[alertId];
@ -363,30 +410,10 @@ class SwarmRoadAlertService {
} }
_userVotes.add(voteKey); _userVotes.add(voteKey);
final previousCached = cached;
final now = DateTime.now(); final now = DateTime.now();
final firestore = _firestore; // Optimistically update local cache
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;
}
}
if (cached != null) { if (cached != null) {
final updatedDismissals = cached.dismissals + 1; final updatedDismissals = cached.dismissals + 1;
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { 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; return true;
} }

View File

@ -210,7 +210,7 @@ void main() {
expect(expiresAt.difference(reportedAt).inMinutes, 120); 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( final report = await service.reportAlert(
category: RoadAlertCategory.police, category: RoadAlertCategory.police,
latitude: 34.0, latitude: 34.0,
@ -230,9 +230,18 @@ void main() {
expect(voteDoc.data()!['type'], 'confirm'); expect(voteDoc.data()!['type'], 'confirm');
expect(voteDoc.data()!['voterUid'], 'rider_trailing'); expect(voteDoc.data()!['voterUid'], 'rider_trailing');
expect(service.getCachedReport(report.id)?.confirmations, 2); 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( final report = await service.reportAlert(
category: RoadAlertCategory.hazard, category: RoadAlertCategory.hazard,
latitude: 34.0, latitude: 34.0,
@ -251,6 +260,9 @@ void main() {
expect(voteA.exists, isTrue); expect(voteA.exists, isTrue);
expect(voteA.data()!['type'], 'dismiss'); expect(voteA.data()!['type'], 'dismiss');
expect(service.getCachedReport(report.id)?.dismissals, 1); 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 // 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b'); final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
@ -265,6 +277,45 @@ void main() {
expect(voteB.data()!['type'], 'dismiss'); expect(voteB.data()!['type'], 'dismiss');
expect(service.getCachedReport(report.id)?.dismissals, 2); expect(service.getCachedReport(report.id)?.dismissals, 2);
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared); 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 { test('getAlertsNear finds alerts within radius and excludes distant ones', () async {