fix: harden route security and persistence
This commit is contained in:
parent
cbeccb6063
commit
ae13dd81e1
|
|
@ -1157,6 +1157,15 @@ describe('Social Architecture Security Rules', () => {
|
||||||
riderHandle: 'bob',
|
riderHandle: 'bob',
|
||||||
riderDisplayName: 'Bob',
|
riderDisplayName: 'Bob',
|
||||||
});
|
});
|
||||||
|
await setDoc(doc(db, 'routes/route_official_alice'), {
|
||||||
|
id: 'route_official_alice',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Alice Official Route',
|
||||||
|
visibility: 'private',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
|
@ -1192,6 +1201,308 @@ describe('Social Architecture Security Rules', () => {
|
||||||
routeStopCount: null,
|
routeStopCount: null,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('group-only route read authorization enforces accepted status and mutual block privacy', async () => {
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix'), {
|
||||||
|
id: 'ride_go_matrix',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
title: 'Matrix Group Ride',
|
||||||
|
status: 'scheduled',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 6,
|
||||||
|
visibility: 'public',
|
||||||
|
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. accepted participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/bob'), {
|
||||||
|
userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob',
|
||||||
|
});
|
||||||
|
// 2. invited participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/charlie'), {
|
||||||
|
userId: 'charlie', role: 'participant', status: 'invited', riderHandle: 'charlie', riderDisplayName: 'Charlie',
|
||||||
|
});
|
||||||
|
// 3. requested participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/dave'), {
|
||||||
|
userId: 'dave', role: 'participant', status: 'requested', riderHandle: 'dave', riderDisplayName: 'Dave',
|
||||||
|
});
|
||||||
|
// 4. left participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/eve'), {
|
||||||
|
userId: 'eve', role: 'participant', status: 'left', riderHandle: 'eve', riderDisplayName: 'Eve',
|
||||||
|
});
|
||||||
|
// 5. removed participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/frank'), {
|
||||||
|
userId: 'frank', role: 'participant', status: 'removed', riderHandle: 'frank', riderDisplayName: 'Frank',
|
||||||
|
});
|
||||||
|
// 6. accepted rider but mutually blocked with owner Alice
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/mallory'), {
|
||||||
|
userId: 'mallory', role: 'participant', status: 'accepted', riderHandle: 'mallory', riderDisplayName: 'Mallory',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'users/alice/blocks/mallory'), { blocked: true });
|
||||||
|
|
||||||
|
// Route attached to this group ride
|
||||||
|
await setDoc(doc(db, 'routes/route_go_matrix_valid'), {
|
||||||
|
id: 'route_go_matrix_valid',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Matrix Group Route',
|
||||||
|
visibility: 'groupOnly',
|
||||||
|
attachedGroupRideId: 'ride_go_matrix',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route marked groupOnly but with no attached group ride
|
||||||
|
await setDoc(doc(db, 'routes/route_go_matrix_no_group'), {
|
||||||
|
id: 'route_go_matrix_no_group',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Orphan Group Route',
|
||||||
|
visibility: 'groupOnly',
|
||||||
|
attachedGroupRideId: null,
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||||
|
const eve = testEnv.authenticatedContext('eve').firestore();
|
||||||
|
const frank = testEnv.authenticatedContext('frank').firestore();
|
||||||
|
const mallory = testEnv.authenticatedContext('mallory').firestore();
|
||||||
|
const outsider = testEnv.authenticatedContext('outsider').firestore();
|
||||||
|
|
||||||
|
const validRouteRef = (db) => doc(db, 'routes/route_go_matrix_valid');
|
||||||
|
const orphanRouteRef = (db) => doc(db, 'routes/route_go_matrix_no_group');
|
||||||
|
|
||||||
|
// accepted participant -> ALLOW
|
||||||
|
await assertSucceeds(getDoc(validRouteRef(bob)));
|
||||||
|
|
||||||
|
// invited participant -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(charlie)));
|
||||||
|
|
||||||
|
// requested participant -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(dave)));
|
||||||
|
|
||||||
|
// left participant -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(eve)));
|
||||||
|
|
||||||
|
// removed participant -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(frank)));
|
||||||
|
|
||||||
|
// blocked accepted rider -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(mallory)));
|
||||||
|
|
||||||
|
// outsider -> DENY
|
||||||
|
await assertFails(getDoc(validRouteRef(outsider)));
|
||||||
|
|
||||||
|
// route with no attached group ride -> DENY (even for accepted rider Bob)
|
||||||
|
await assertFails(getDoc(orphanRouteRef(bob)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('group ride route attachment requires organizer to own the route', async () => {
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_attach_ownership'), {
|
||||||
|
id: 'ride_attach_ownership',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
title: 'Ownership Group Ride',
|
||||||
|
status: 'scheduled',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 2,
|
||||||
|
visibility: 'public',
|
||||||
|
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_attach_ownership/participants/bob'), {
|
||||||
|
userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'routes/route_alice_owned'), {
|
||||||
|
id: 'route_alice_owned',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Alice Route',
|
||||||
|
visibility: 'private',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'routes/route_bob_owned'), {
|
||||||
|
id: 'route_bob_owned',
|
||||||
|
ownerUid: 'bob',
|
||||||
|
title: 'Bob Route',
|
||||||
|
visibility: 'private',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const rideRef = (db) => doc(db, 'group_rides/ride_attach_ownership');
|
||||||
|
|
||||||
|
// 1. Organizer Alice attaching route she owns -> ALLOW
|
||||||
|
await assertSucceeds(updateDoc(rideRef(alice), {
|
||||||
|
routeId: 'route_alice_owned',
|
||||||
|
routeTitle: 'Alice Route',
|
||||||
|
routeDistanceMeters: 10000,
|
||||||
|
routeStopCount: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Organizer Alice attaching another user's route (Bob's route) -> DENY
|
||||||
|
await assertFails(updateDoc(rideRef(alice), {
|
||||||
|
routeId: 'route_bob_owned',
|
||||||
|
routeTitle: 'Bob Route',
|
||||||
|
routeDistanceMeters: 10000,
|
||||||
|
routeStopCount: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Non-organizer participant Bob attaching his own route -> DENY
|
||||||
|
await assertFails(updateDoc(rideRef(bob), {
|
||||||
|
routeId: 'route_bob_owned',
|
||||||
|
routeTitle: 'Bob Route',
|
||||||
|
routeDistanceMeters: 10000,
|
||||||
|
routeStopCount: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Organizer Alice attaching non-existent route -> DENY
|
||||||
|
await assertFails(updateDoc(rideRef(alice), {
|
||||||
|
routeId: 'route_non_existent',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route schema immutability and visibility whitelisting', async () => {
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'routes/route_immutable_test'), {
|
||||||
|
id: 'route_immutable_test',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Original Title',
|
||||||
|
visibility: 'private',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const routeRef = doc(alice, 'routes/route_immutable_test');
|
||||||
|
|
||||||
|
// 1. Updating title: ALLOW
|
||||||
|
await assertSucceeds(updateDoc(routeRef, {
|
||||||
|
title: 'Updated Safe Title',
|
||||||
|
updatedAt: '2026-09-04T13:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Modifying immutable ownerUid: DENY
|
||||||
|
await assertFails(updateDoc(routeRef, {
|
||||||
|
ownerUid: 'bob',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Modifying immutable id: DENY
|
||||||
|
await assertFails(updateDoc(routeRef, {
|
||||||
|
id: 'different_id',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Modifying immutable createdAt: DENY
|
||||||
|
await assertFails(updateDoc(routeRef, {
|
||||||
|
createdAt: '2026-09-05T00:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5. Forging ownerUid on create: DENY
|
||||||
|
await assertFails(setDoc(doc(bob, 'routes/route_forged_bob'), {
|
||||||
|
id: 'route_forged_bob',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Forged Owner Route',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. Unsupported visibility: DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_invalid_visibility'), {
|
||||||
|
id: 'route_invalid_visibility',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Invalid Visibility Route',
|
||||||
|
visibility: 'secret_unsupported',
|
||||||
|
stops: [],
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route stops validation enforces bounds, types, and required fields', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
|
||||||
|
const baseRoute = (routeId, stops) => ({
|
||||||
|
id: routeId,
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Stop Validation Route',
|
||||||
|
visibility: 'private',
|
||||||
|
stops,
|
||||||
|
createdAt: '2026-09-04T12:00:00Z',
|
||||||
|
updatedAt: '2026-09-04T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeStop = (overrides = {}) => ({
|
||||||
|
id: 'stop-valid-1',
|
||||||
|
order: 0,
|
||||||
|
stopType: 'waypoint',
|
||||||
|
displayName: 'Valid Stop',
|
||||||
|
latitude: 37.77,
|
||||||
|
longitude: -122.41,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Valid stop passes
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'routes/route_stop_valid'), baseRoute('route_stop_valid', [makeStop()])));
|
||||||
|
|
||||||
|
// 2. Latitude > 90 -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_high'), baseRoute('route_stop_lat_high', [makeStop({ latitude: 90.001 })])));
|
||||||
|
|
||||||
|
// 3. Latitude < -90 -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_low'), baseRoute('route_stop_lat_low', [makeStop({ latitude: -90.001 })])));
|
||||||
|
|
||||||
|
// 4. Longitude > 180 -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_high'), baseRoute('route_stop_lng_high', [makeStop({ longitude: 180.001 })])));
|
||||||
|
|
||||||
|
// 5. Longitude < -180 -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_low'), baseRoute('route_stop_lng_low', [makeStop({ longitude: -180.001 })])));
|
||||||
|
|
||||||
|
// 6. Unsupported stopType -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_bad_type'), baseRoute('route_stop_bad_type', [makeStop({ stopType: 'hyperloop' })])));
|
||||||
|
|
||||||
|
// 7. Empty stop ID -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_id'), baseRoute('route_stop_empty_id', [makeStop({ id: '' })])));
|
||||||
|
|
||||||
|
// 8. Empty displayName -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_name'), baseRoute('route_stop_empty_name', [makeStop({ displayName: '' })])));
|
||||||
|
|
||||||
|
// 9. Oversized displayName (> 80 chars) -> DENY
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_long_name'), baseRoute('route_stop_long_name', [makeStop({ displayName: 'A'.repeat(81) })])));
|
||||||
|
|
||||||
|
// 10. Malformed stop object (missing latitude) -> DENY
|
||||||
|
const malformedStop = {
|
||||||
|
id: 'stop-bad',
|
||||||
|
order: 0,
|
||||||
|
stopType: 'waypoint',
|
||||||
|
displayName: 'Bad Stop',
|
||||||
|
longitude: -122.41,
|
||||||
|
};
|
||||||
|
await assertFails(setDoc(doc(alice, 'routes/route_stop_malformed'), baseRoute('route_stop_malformed', [malformedStop])));
|
||||||
|
|
||||||
|
// 11. 25 stops (technical cap) -> ALLOW
|
||||||
|
const maxStops = [];
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
maxStops.push(makeStop({ id: `stop-${i}`, order: i, displayName: `Stop ${i}` }));
|
||||||
|
}
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'routes/route_25_stops'), baseRoute('route_25_stops', maxStops)));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
116
firestore.rules
116
firestore.rules
|
|
@ -220,7 +220,15 @@ service cloud.firestore {
|
||||||
&& request.resource.data.organizerUid == request.auth.uid
|
&& request.resource.data.organizerUid == request.auth.uid
|
||||||
&& request.resource.data.id == groupRideId
|
&& request.resource.data.id == groupRideId
|
||||||
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
|
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
|
||||||
&& request.resource.data.participantCount >= 1;
|
&& request.resource.data.participantCount >= 1
|
||||||
|
&& (
|
||||||
|
!('routeId' in request.resource.data) ||
|
||||||
|
request.resource.data.routeId == null ||
|
||||||
|
(
|
||||||
|
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
||||||
|
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
|
||||||
|
)
|
||||||
|
);
|
||||||
function validStatusTransition() {
|
function validStatusTransition() {
|
||||||
let prev = resource.data.status;
|
let prev = resource.data.status;
|
||||||
let next = request.resource.data.status;
|
let next = request.resource.data.status;
|
||||||
|
|
@ -230,9 +238,21 @@ service cloud.firestore {
|
||||||
(prev == 'underway' && next in ['completed', 'canceled']);
|
(prev == 'underway' && next in ['completed', 'canceled']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validRouteAttachment() {
|
||||||
|
return !('routeId' in request.resource.data) ||
|
||||||
|
request.resource.data.routeId == null ||
|
||||||
|
(
|
||||||
|
(resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) ||
|
||||||
|
(
|
||||||
|
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
||||||
|
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
allow update: if signedIn() && (
|
allow update: if signedIn() && (
|
||||||
// Organizer can update ride authority fields with valid status transitions
|
// Organizer can update ride authority fields with valid status transitions and owned route attachment
|
||||||
(isOrganizer() && validStatusTransition()) ||
|
(isOrganizer() && validStatusTransition() && validRouteAttachment()) ||
|
||||||
// Participants/invitees can only atomically adjust participantCount during join/leave transactions
|
// Participants/invitees can only atomically adjust participantCount during join/leave transactions
|
||||||
(
|
(
|
||||||
canReadRide() &&
|
canReadRide() &&
|
||||||
|
|
@ -367,6 +387,11 @@ service cloud.firestore {
|
||||||
return signedIn() && resource.data.ownerUid == request.auth.uid;
|
return signedIn() && resource.data.ownerUid == request.auth.uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAcceptedGroupParticipant(groupRideId, uid) {
|
||||||
|
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||||
|
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
|
||||||
|
}
|
||||||
|
|
||||||
function canReadRoute() {
|
function canReadRoute() {
|
||||||
return signedIn() && (
|
return signedIn() && (
|
||||||
isRouteOwner() ||
|
isRouteOwner() ||
|
||||||
|
|
@ -376,34 +401,94 @@ service cloud.firestore {
|
||||||
(
|
(
|
||||||
resource.data.visibility == 'groupOnly' &&
|
resource.data.visibility == 'groupOnly' &&
|
||||||
resource.data.attachedGroupRideId != null &&
|
resource.data.attachedGroupRideId != null &&
|
||||||
exists(/databases/$(database)/documents/group_rides/$(resource.data.attachedGroupRideId)/participants/$(request.auth.uid))
|
isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validates embedded stop representation.
|
||||||
|
// Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing,
|
||||||
|
// and exhaustive validation of all stops across large routes.
|
||||||
|
// Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits.
|
||||||
|
function validStop(stop) {
|
||||||
|
return stop is map
|
||||||
|
&& stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64
|
||||||
|
&& stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80
|
||||||
|
&& (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0
|
||||||
|
&& (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0
|
||||||
|
&& (
|
||||||
|
('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) ||
|
||||||
|
('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validStops(stops) {
|
||||||
|
return stops is list
|
||||||
|
&& stops.size() <= 25
|
||||||
|
&& (stops.size() < 1 || validStop(stops[0]))
|
||||||
|
&& (stops.size() < 2 || validStop(stops[1]))
|
||||||
|
&& (stops.size() < 3 || validStop(stops[2]))
|
||||||
|
&& (stops.size() < 4 || validStop(stops[3]))
|
||||||
|
&& (stops.size() < 5 || validStop(stops[4]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRouteKeys(data) {
|
||||||
|
return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt'])
|
||||||
|
&& data.keys().hasOnly([
|
||||||
|
'id', 'ownerUid', 'ownerHandle', 'title', 'description',
|
||||||
|
'visibility', 'stops', 'straightLineDistanceMeters',
|
||||||
|
'estimatedDistanceMeters', 'estimatedDurationSeconds',
|
||||||
|
'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRouteCommon(data) {
|
||||||
|
return validRouteKeys(data)
|
||||||
|
&& data.title is string
|
||||||
|
&& data.title.size() >= 3
|
||||||
|
&& data.title.size() <= 80
|
||||||
|
&& (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40))
|
||||||
|
&& (!('description' in data) || (data.description is string && data.description.size() <= 500))
|
||||||
|
&& (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public'])
|
||||||
|
&& (
|
||||||
|
!('visibility' in data) ||
|
||||||
|
data.visibility != 'groupOnly' ||
|
||||||
|
(('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0)
|
||||||
|
)
|
||||||
|
&& (
|
||||||
|
!('attachedGroupRideId' in data) ||
|
||||||
|
data.attachedGroupRideId == null ||
|
||||||
|
(
|
||||||
|
data.attachedGroupRideId is string &&
|
||||||
|
data.attachedGroupRideId.size() > 0 &&
|
||||||
|
data.attachedGroupRideId.size() <= 64 &&
|
||||||
|
exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) &&
|
||||||
|
get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
&& (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0))
|
||||||
|
&& (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0))
|
||||||
|
&& (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0))
|
||||||
|
&& (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion'])
|
||||||
|
&& (!('version' in data) || (data.version is int && data.version >= 1))
|
||||||
|
&& validStops(data.stops);
|
||||||
|
}
|
||||||
|
|
||||||
function validRouteCreate() {
|
function validRouteCreate() {
|
||||||
return signedIn()
|
return signedIn()
|
||||||
&& request.resource.data.id == routeId
|
&& request.resource.data.id == routeId
|
||||||
&& request.resource.data.ownerUid == request.auth.uid
|
&& request.resource.data.ownerUid == request.auth.uid
|
||||||
&& request.resource.data.title is string
|
&& validRouteCommon(request.resource.data);
|
||||||
&& request.resource.data.title.size() >= 3
|
|
||||||
&& request.resource.data.title.size() <= 80
|
|
||||||
&& request.resource.data.stops is list
|
|
||||||
&& request.resource.data.stops.size() <= 25
|
|
||||||
&& request.resource.data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validRouteUpdate() {
|
function validRouteUpdate() {
|
||||||
return isRouteOwner()
|
return isRouteOwner()
|
||||||
&& request.resource.data.id == resource.data.id
|
&& request.resource.data.id == resource.data.id
|
||||||
&& request.resource.data.ownerUid == resource.data.ownerUid
|
&& request.resource.data.ownerUid == resource.data.ownerUid
|
||||||
&& request.resource.data.title is string
|
&& request.resource.data.createdAt == resource.data.createdAt
|
||||||
&& request.resource.data.title.size() >= 3
|
&& validRouteCommon(request.resource.data);
|
||||||
&& request.resource.data.title.size() <= 80
|
|
||||||
&& request.resource.data.stops is list
|
|
||||||
&& request.resource.data.stops.size() <= 25;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allow read: if canReadRoute();
|
allow read: if canReadRoute();
|
||||||
|
|
@ -417,4 +502,3 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1024,9 +1024,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
if (user == null || user.uid.isEmpty) {
|
||||||
|
throw StateError('Sign-in required to detach route.');
|
||||||
|
}
|
||||||
await ref
|
await ref
|
||||||
.read(routeServiceProvider)
|
.read(routeServiceProvider)
|
||||||
.detachRouteFromGroupRide(ride.id);
|
.detachRouteFromGroupRide(
|
||||||
|
ride.id,
|
||||||
|
callerUid: user.uid,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
|
|
@ -1230,11 +1237,17 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(sheetCtx);
|
Navigator.pop(sheetCtx);
|
||||||
try {
|
try {
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
if (user == null || user.uid.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
'Sign-in required to attach route.');
|
||||||
|
}
|
||||||
await ref
|
await ref
|
||||||
.read(routeServiceProvider)
|
.read(routeServiceProvider)
|
||||||
.attachRouteToGroupRide(
|
.attachRouteToGroupRide(
|
||||||
groupRideId: ride.id,
|
groupRideId: ride.id,
|
||||||
route: route,
|
route: route,
|
||||||
|
callerUid: user.uid,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,15 @@ class _RouteDetailScreenState extends ConsumerState<RouteDetailScreen> {
|
||||||
|
|
||||||
setState(() => _isDeleting = true);
|
setState(() => _isDeleting = true);
|
||||||
try {
|
try {
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
if (user == null || user.uid.isEmpty) {
|
||||||
|
throw StateError('Sign-in required to delete route.');
|
||||||
|
}
|
||||||
final service = ref.read(routeServiceProvider);
|
final service = ref.read(routeServiceProvider);
|
||||||
await service.deleteRoute(widget.routeId);
|
await service.deleteRoute(
|
||||||
|
widget.routeId,
|
||||||
|
callerUid: user.uid,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../services/auth/auth_providers.dart';
|
import '../../services/auth/auth_providers.dart';
|
||||||
import '../../services/entitlements/entitlement_providers.dart';
|
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
import '../../services/map/unified_map_view.dart';
|
import '../../services/map/unified_map_view.dart';
|
||||||
import '../../services/routes/navigation_handoff.dart';
|
import '../../services/routes/navigation_handoff.dart';
|
||||||
|
|
@ -107,27 +106,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
String? defaultName,
|
String? defaultName,
|
||||||
}) {
|
}) {
|
||||||
final state = ref.read(routePlannerNotifierProvider);
|
final state = ref.read(routePlannerNotifierProvider);
|
||||||
final isPro = ref.read(isProSubscriberProvider);
|
const maxAllowed = RouteService.maxTechnicalStops;
|
||||||
final maxAllowed = isPro
|
|
||||||
? RouteService.maxTechnicalStops
|
|
||||||
: RouteService.freeStopCap;
|
|
||||||
|
|
||||||
if (existingStop == null && state.stops.length >= maxAllowed) {
|
if (existingStop == null && state.stops.length >= maxAllowed) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Maximum of $maxAllowed stops reached.'),
|
||||||
isPro
|
backgroundColor: Color(0xFF1B1E26),
|
||||||
? 'Maximum of $maxAllowed stops reached.'
|
|
||||||
: 'Free tier limit reached ($maxAllowed stops). Upgrade to Pro for up to 25 stops.',
|
|
||||||
),
|
|
||||||
action: !isPro
|
|
||||||
? SnackBarAction(
|
|
||||||
label: 'UPGRADE',
|
|
||||||
textColor: const Color(0xFF00E5FF),
|
|
||||||
onPressed: () => context.push('/settings/pro'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
backgroundColor: const Color(0xFF1B1E26),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -381,17 +366,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isPro = ref.read(isProSubscriberProvider);
|
|
||||||
final state = ref.read(routePlannerNotifierProvider);
|
final state = ref.read(routePlannerNotifierProvider);
|
||||||
final maxAllowed = isPro
|
const maxAllowed = RouteService.maxTechnicalStops;
|
||||||
? RouteService.maxTechnicalStops
|
|
||||||
: RouteService.freeStopCap;
|
|
||||||
|
|
||||||
if (state.stops.length > maxAllowed) {
|
if (state.stops.length > maxAllowed) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = isPro
|
_errorMessage = 'Cannot exceed $maxAllowed stops.';
|
||||||
? 'Cannot exceed $maxAllowed stops.'
|
|
||||||
: 'Free tier limit is $maxAllowed stops. Upgrade to Pro for more.';
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -417,6 +397,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
await service.attachRouteToGroupRide(
|
await service.attachRouteToGroupRide(
|
||||||
groupRideId: widget.attachToGroupRideId!,
|
groupRideId: widget.attachToGroupRideId!,
|
||||||
route: savedRoute,
|
route: savedRoute,
|
||||||
|
callerUid: user.uid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,10 +458,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final state = ref.watch(routePlannerNotifierProvider);
|
final state = ref.watch(routePlannerNotifierProvider);
|
||||||
final isPro = ref.watch(isProSubscriberProvider);
|
const maxAllowed = RouteService.maxTechnicalStops;
|
||||||
final maxAllowed = isPro
|
|
||||||
? RouteService.maxTechnicalStops
|
|
||||||
: RouteService.freeStopCap;
|
|
||||||
final isAtLimit = state.stops.length >= maxAllowed;
|
final isAtLimit = state.stops.length >= maxAllowed;
|
||||||
|
|
||||||
// Center map on first stop or default
|
// Center map on first stop or default
|
||||||
|
|
@ -807,26 +785,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
if (!isPro && isAtLimit)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.push('/settings/pro'),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Upgrade for 25 Stops',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFFFFB300),
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../auth/auth_providers.dart';
|
import '../auth/auth_providers.dart';
|
||||||
import '../entitlements/entitlement_providers.dart';
|
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
import 'navigation_handoff.dart';
|
import 'navigation_handoff.dart';
|
||||||
import 'route_models.dart';
|
import 'route_models.dart';
|
||||||
|
|
@ -62,6 +61,7 @@ class RoutePlannerState {
|
||||||
final bool isSaving;
|
final bool isSaving;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final bool isDirty;
|
final bool isDirty;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
const RoutePlannerState({
|
const RoutePlannerState({
|
||||||
this.routeId,
|
this.routeId,
|
||||||
|
|
@ -73,6 +73,7 @@ class RoutePlannerState {
|
||||||
this.isSaving = false,
|
this.isSaving = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.isDirty = false,
|
this.isDirty = false,
|
||||||
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
||||||
|
|
@ -97,6 +98,7 @@ class RoutePlannerState {
|
||||||
bool? isSaving,
|
bool? isSaving,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
bool? isDirty,
|
bool? isDirty,
|
||||||
|
DateTime? createdAt,
|
||||||
}) {
|
}) {
|
||||||
return RoutePlannerState(
|
return RoutePlannerState(
|
||||||
routeId: routeId ?? this.routeId,
|
routeId: routeId ?? this.routeId,
|
||||||
|
|
@ -109,6 +111,7 @@ class RoutePlannerState {
|
||||||
isSaving: isSaving ?? this.isSaving,
|
isSaving: isSaving ?? this.isSaving,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
isDirty: isDirty ?? this.isDirty,
|
isDirty: isDirty ?? this.isDirty,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +131,7 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
stops: initialRoute.orderedStops,
|
stops: initialRoute.orderedStops,
|
||||||
straightLineDistanceMeters:
|
straightLineDistanceMeters:
|
||||||
initialRoute.straightLineDistanceMeters,
|
initialRoute.straightLineDistanceMeters,
|
||||||
|
createdAt: initialRoute.createdAt,
|
||||||
)
|
)
|
||||||
: const RoutePlannerState(),
|
: const RoutePlannerState(),
|
||||||
);
|
);
|
||||||
|
|
@ -140,6 +144,7 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
visibility: route.visibility,
|
visibility: route.visibility,
|
||||||
stops: route.orderedStops,
|
stops: route.orderedStops,
|
||||||
straightLineDistanceMeters: route.straightLineDistanceMeters,
|
straightLineDistanceMeters: route.straightLineDistanceMeters,
|
||||||
|
createdAt: route.createdAt,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +313,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
throw StateError('Sign-in required to save routes.');
|
throw StateError('Sign-in required to save routes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final isPro = ref.read(isProSubscriberProvider);
|
|
||||||
final service = ref.read(routeServiceProvider);
|
final service = ref.read(routeServiceProvider);
|
||||||
|
|
||||||
state = state.copyWith(isSaving: true, errorMessage: null);
|
state = state.copyWith(isSaving: true, errorMessage: null);
|
||||||
|
|
@ -322,8 +326,8 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
ownerHandle: user?.email?.split('@').first ?? 'rider',
|
ownerHandle: user?.email?.split('@').first ?? 'rider',
|
||||||
visibility: visibility ?? state.visibility,
|
visibility: visibility ?? state.visibility,
|
||||||
stops: state.stops,
|
stops: state.stops,
|
||||||
isPro: isPro,
|
|
||||||
attachedGroupRideId: attachedGroupRideId,
|
attachedGroupRideId: attachedGroupRideId,
|
||||||
|
createdAt: state.createdAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,28 @@ class RouteService {
|
||||||
/// Technical safety bound for maximum stops in a single route.
|
/// Technical safety bound for maximum stops in a single route.
|
||||||
static const int maxTechnicalStops = 25;
|
static const int maxTechnicalStops = 25;
|
||||||
|
|
||||||
/// Free tier recommended stop cap.
|
|
||||||
static const int freeStopCap = 8;
|
|
||||||
|
|
||||||
/// Validates route stops invariants.
|
/// Validates route stops invariants.
|
||||||
static void validateStops(List<RouteStop> stops, {bool isPro = false}) {
|
///
|
||||||
|
/// Note on stop validation:
|
||||||
|
/// Client-side and domain validation strictly enforces unique stop IDs and sequential 0..N-1 ordering.
|
||||||
|
/// Firestore Security Rules validate stop schema, bounding, and technical capacity (<= 25),
|
||||||
|
/// but exhaustive O(N^2) duplicate-ID comparison is impractical in Firestore Rules for embedded arrays.
|
||||||
|
static void validateStops(List<RouteStop> stops) {
|
||||||
if (stops.length > maxTechnicalStops) {
|
if (stops.length > maxTechnicalStops) {
|
||||||
throw ArgumentError(
|
throw ArgumentError(
|
||||||
'Route cannot exceed $maxTechnicalStops stops.',
|
'Route cannot exceed $maxTechnicalStops stops.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isPro && stops.length > freeStopCap) {
|
|
||||||
throw ArgumentError(
|
|
||||||
'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final seenIds = <String>{};
|
final seenIds = <String>{};
|
||||||
for (int i = 0; i < stops.length; i++) {
|
for (int i = 0; i < stops.length; i++) {
|
||||||
final s = stops[i];
|
final s = stops[i];
|
||||||
if (s.id.isEmpty) {
|
if (s.id.trim().isEmpty) {
|
||||||
throw ArgumentError('Route stop at index $i is missing an ID.');
|
throw ArgumentError('Route stop at index $i is missing an ID.');
|
||||||
}
|
}
|
||||||
|
if (s.id.length > 64) {
|
||||||
|
throw ArgumentError('Route stop ID exceeds 64 characters.');
|
||||||
|
}
|
||||||
if (seenIds.contains(s.id)) {
|
if (seenIds.contains(s.id)) {
|
||||||
throw ArgumentError('Duplicate route stop ID: ${s.id}');
|
throw ArgumentError('Duplicate route stop ID: ${s.id}');
|
||||||
}
|
}
|
||||||
|
|
@ -65,10 +65,21 @@ class RouteService {
|
||||||
if (s.displayName.trim().isEmpty) {
|
if (s.displayName.trim().isEmpty) {
|
||||||
throw ArgumentError('Stop name cannot be empty.');
|
throw ArgumentError('Stop name cannot be empty.');
|
||||||
}
|
}
|
||||||
|
if (s.displayName.length > 80) {
|
||||||
|
throw ArgumentError('Stop name cannot exceed 80 characters.');
|
||||||
|
}
|
||||||
|
if (s.address.length > 200) {
|
||||||
|
throw ArgumentError('Stop address cannot exceed 200 characters.');
|
||||||
|
}
|
||||||
|
if (s.notes.length > 500) {
|
||||||
|
throw ArgumentError('Stop notes cannot exceed 500 characters.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves or creates a route (offline-safe when Firestore is unconfigured).
|
/// Saves or creates a route in persistent cloud storage.
|
||||||
|
///
|
||||||
|
/// Fails explicitly if persistence is unavailable.
|
||||||
Future<MotorSignalRoute> saveRoute({
|
Future<MotorSignalRoute> saveRoute({
|
||||||
String? id,
|
String? id,
|
||||||
required String title,
|
required String title,
|
||||||
|
|
@ -77,17 +88,37 @@ class RouteService {
|
||||||
String ownerHandle = '',
|
String ownerHandle = '',
|
||||||
RouteVisibility visibility = RouteVisibility.private,
|
RouteVisibility visibility = RouteVisibility.private,
|
||||||
required List<RouteStop> stops,
|
required List<RouteStop> stops,
|
||||||
bool isPro = false,
|
|
||||||
String? attachedGroupRideId,
|
String? attachedGroupRideId,
|
||||||
|
DateTime? createdAt,
|
||||||
}) async {
|
}) async {
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Route persistence unavailable: cloud database service is not configured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerUid.trim().isEmpty) {
|
||||||
|
throw StateError('Sign-in required to save routes.');
|
||||||
|
}
|
||||||
|
|
||||||
final cleanTitle = title.trim();
|
final cleanTitle = title.trim();
|
||||||
if (cleanTitle.length < 3 || cleanTitle.length > 80) {
|
if (cleanTitle.length < 3 || cleanTitle.length > 80) {
|
||||||
throw ArgumentError('Route title must be between 3 and 80 characters.');
|
throw ArgumentError('Route title must be between 3 and 80 characters.');
|
||||||
}
|
}
|
||||||
|
|
||||||
validateStops(stops, isPro: isPro);
|
if (visibility == RouteVisibility.groupOnly &&
|
||||||
|
(attachedGroupRideId == null || attachedGroupRideId.isEmpty)) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Group-only route visibility requires an attached group ride ID.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateStops(stops);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final effectiveCreatedAt = createdAt ?? now;
|
||||||
|
final effectiveUpdatedAt = now;
|
||||||
final routeId = id ??
|
final routeId = id ??
|
||||||
'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}';
|
'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}';
|
||||||
|
|
||||||
|
|
@ -114,15 +145,11 @@ class RouteService {
|
||||||
source: RouteSource.planner,
|
source: RouteSource.planner,
|
||||||
version: 1,
|
version: 1,
|
||||||
attachedGroupRideId: attachedGroupRideId,
|
attachedGroupRideId: attachedGroupRideId,
|
||||||
createdAt: now,
|
createdAt: effectiveCreatedAt,
|
||||||
updatedAt: now,
|
updatedAt: effectiveUpdatedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
final firestore = _firestore;
|
await firestore.collection('routes').doc(routeId).set(route.toJson());
|
||||||
if (firestore != null) {
|
|
||||||
await firestore.collection('routes').doc(routeId).set(route.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,7 +161,6 @@ class RouteService {
|
||||||
String description = '',
|
String description = '',
|
||||||
RouteVisibility visibility = RouteVisibility.private,
|
RouteVisibility visibility = RouteVisibility.private,
|
||||||
required List<RouteStop> stops,
|
required List<RouteStop> stops,
|
||||||
bool isPro = false,
|
|
||||||
String? attachedGroupRideId,
|
String? attachedGroupRideId,
|
||||||
}) async {
|
}) async {
|
||||||
return saveRoute(
|
return saveRoute(
|
||||||
|
|
@ -144,7 +170,6 @@ class RouteService {
|
||||||
description: description,
|
description: description,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
stops: stops,
|
stops: stops,
|
||||||
isPro: isPro,
|
|
||||||
attachedGroupRideId: attachedGroupRideId,
|
attachedGroupRideId: attachedGroupRideId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -189,12 +214,14 @@ class RouteService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates an existing route.
|
/// Updates an existing route, preserving original creation provenance.
|
||||||
Future<MotorSignalRoute> updateRoute({
|
Future<MotorSignalRoute> updateRoute({
|
||||||
required MotorSignalRoute route,
|
required MotorSignalRoute route,
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
bool isPro = false,
|
|
||||||
}) async {
|
}) async {
|
||||||
|
if (callerUid.trim().isEmpty) {
|
||||||
|
throw StateError('Caller identity required to update route.');
|
||||||
|
}
|
||||||
if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) {
|
if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) {
|
||||||
throw StateError('Only the route owner can update this route.');
|
throw StateError('Only the route owner can update this route.');
|
||||||
}
|
}
|
||||||
|
|
@ -203,37 +230,46 @@ class RouteService {
|
||||||
id: route.id,
|
id: route.id,
|
||||||
title: route.title,
|
title: route.title,
|
||||||
description: route.description,
|
description: route.description,
|
||||||
ownerUid: callerUid,
|
ownerUid: route.ownerUid.isNotEmpty ? route.ownerUid : callerUid,
|
||||||
ownerHandle: route.ownerHandle,
|
ownerHandle: route.ownerHandle,
|
||||||
visibility: route.visibility,
|
visibility: route.visibility,
|
||||||
stops: route.stops,
|
stops: route.stops,
|
||||||
isPro: isPro,
|
|
||||||
attachedGroupRideId: route.attachedGroupRideId,
|
attachedGroupRideId: route.attachedGroupRideId,
|
||||||
|
createdAt: route.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a route safely.
|
/// Deletes a route safely and atomically.
|
||||||
///
|
///
|
||||||
/// If the route is attached to a Group Ride, it cleanly clears the
|
/// Caller must be the route owner. If the route is attached to a Group Ride:
|
||||||
/// attachment reference without destructive cascades.
|
/// - If caller is also the Group Ride organizer, clears attachment and deletes atomically.
|
||||||
|
/// - If caller is not the organizer, refuses deletion and requires organizer detach first.
|
||||||
Future<void> deleteRoute(
|
Future<void> deleteRoute(
|
||||||
dynamic routeIdOrNamed, {
|
dynamic routeIdOrNamed, {
|
||||||
String? routeId,
|
String? routeId,
|
||||||
String? callerUid,
|
required String callerUid,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (callerUid.trim().isEmpty) {
|
||||||
|
throw StateError('Caller identity required to delete route.');
|
||||||
|
}
|
||||||
|
|
||||||
final effectiveRouteId =
|
final effectiveRouteId =
|
||||||
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
|
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
|
||||||
if (effectiveRouteId.isEmpty) return;
|
if (effectiveRouteId.isEmpty) return;
|
||||||
|
|
||||||
final firestore = _firestore;
|
final firestore = _firestore;
|
||||||
if (firestore == null) return;
|
if (firestore == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Route persistence unavailable: cloud database service is not configured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final docRef = firestore.collection('routes').doc(effectiveRouteId);
|
final docRef = firestore.collection('routes').doc(effectiveRouteId);
|
||||||
final snapshot = await docRef.get();
|
final snapshot = await docRef.get();
|
||||||
if (!snapshot.exists || snapshot.data() == null) return;
|
if (!snapshot.exists || snapshot.data() == null) return;
|
||||||
|
|
||||||
final data = snapshot.data()!;
|
final data = snapshot.data()!;
|
||||||
if (callerUid != null && data['ownerUid'] != callerUid) {
|
if (data['ownerUid'] != callerUid) {
|
||||||
throw StateError('Only the route owner can delete this route.');
|
throw StateError('Only the route owner can delete this route.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,14 +277,24 @@ class RouteService {
|
||||||
if (attachedRideId != null && attachedRideId.isNotEmpty) {
|
if (attachedRideId != null && attachedRideId.isNotEmpty) {
|
||||||
final rideRef = firestore.collection('group_rides').doc(attachedRideId);
|
final rideRef = firestore.collection('group_rides').doc(attachedRideId);
|
||||||
final rideSnap = await rideRef.get();
|
final rideSnap = await rideRef.get();
|
||||||
if (rideSnap.exists) {
|
if (rideSnap.exists && rideSnap.data() != null) {
|
||||||
await rideRef.update({
|
final rideData = rideSnap.data()!;
|
||||||
|
if (rideData['organizerUid'] != callerUid) {
|
||||||
|
throw StateError(
|
||||||
|
'Cannot delete a route attached to a Group Ride you do not organize. Organizer must detach the route first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final batch = firestore.batch();
|
||||||
|
batch.update(rideRef, {
|
||||||
'routeId': FieldValue.delete(),
|
'routeId': FieldValue.delete(),
|
||||||
'routeTitle': FieldValue.delete(),
|
'routeTitle': FieldValue.delete(),
|
||||||
'routeDistanceMeters': FieldValue.delete(),
|
'routeDistanceMeters': FieldValue.delete(),
|
||||||
'routeStopCount': FieldValue.delete(),
|
'routeStopCount': FieldValue.delete(),
|
||||||
'updatedAt': DateTime.now().toIso8601String(),
|
'updatedAt': DateTime.now().toIso8601String(),
|
||||||
});
|
});
|
||||||
|
batch.delete(docRef);
|
||||||
|
await batch.commit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,15 +303,23 @@ class RouteService {
|
||||||
|
|
||||||
/// Attaches a saved route to a Group Ride.
|
/// Attaches a saved route to a Group Ride.
|
||||||
///
|
///
|
||||||
/// Caller must be the Group Ride organizer.
|
/// Caller must be the Group Ride organizer AND own the route being attached.
|
||||||
Future<void> attachRouteToGroupRide({
|
Future<void> attachRouteToGroupRide({
|
||||||
String? routeId,
|
String? routeId,
|
||||||
MotorSignalRoute? route,
|
MotorSignalRoute? route,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
String? callerUid,
|
required String callerUid,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (callerUid.trim().isEmpty) {
|
||||||
|
throw StateError('Caller identity required to attach route.');
|
||||||
|
}
|
||||||
|
|
||||||
final firestore = _firestore;
|
final firestore = _firestore;
|
||||||
if (firestore == null) return;
|
if (firestore == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Route persistence unavailable: cloud database service is not configured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final targetRouteId = route?.id ?? routeId;
|
final targetRouteId = route?.id ?? routeId;
|
||||||
if (targetRouteId == null || targetRouteId.isEmpty) {
|
if (targetRouteId == null || targetRouteId.isEmpty) {
|
||||||
|
|
@ -279,9 +333,7 @@ class RouteService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final rideData = rideSnap.data()!;
|
final rideData = rideSnap.data()!;
|
||||||
if (callerUid != null &&
|
if (rideData['organizerUid'] != callerUid) {
|
||||||
rideData['organizerUid'] != null &&
|
|
||||||
rideData['organizerUid'] != callerUid) {
|
|
||||||
throw StateError('Only the Group Ride organizer can attach a route.');
|
throw StateError('Only the Group Ride organizer can attach a route.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +342,10 @@ class RouteService {
|
||||||
throw ArgumentError('Route not found.');
|
throw ArgumentError('Route not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (effectiveRoute.ownerUid != callerUid) {
|
||||||
|
throw StateError('Organizer can only attach a route they own.');
|
||||||
|
}
|
||||||
|
|
||||||
final routeRef = firestore.collection('routes').doc(targetRouteId);
|
final routeRef = firestore.collection('routes').doc(targetRouteId);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final batch = firestore.batch();
|
final batch = firestore.batch();
|
||||||
|
|
@ -314,21 +370,30 @@ class RouteService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detaches an attached route from a Group Ride.
|
/// Detaches an attached route from a Group Ride.
|
||||||
|
///
|
||||||
|
/// Caller must be the Group Ride organizer.
|
||||||
|
/// Detaching clears the Group Ride route metadata and restores route visibility to private.
|
||||||
Future<void> detachRouteFromGroupRide(
|
Future<void> detachRouteFromGroupRide(
|
||||||
String groupRideId, {
|
String groupRideId, {
|
||||||
String? callerUid,
|
required String callerUid,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (callerUid.trim().isEmpty) {
|
||||||
|
throw StateError('Caller identity required to detach route.');
|
||||||
|
}
|
||||||
|
|
||||||
final firestore = _firestore;
|
final firestore = _firestore;
|
||||||
if (firestore == null) return;
|
if (firestore == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Route persistence unavailable: cloud database service is not configured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
||||||
final rideSnap = await rideRef.get();
|
final rideSnap = await rideRef.get();
|
||||||
if (!rideSnap.exists || rideSnap.data() == null) return;
|
if (!rideSnap.exists || rideSnap.data() == null) return;
|
||||||
|
|
||||||
final rideData = rideSnap.data()!;
|
final rideData = rideSnap.data()!;
|
||||||
if (callerUid != null &&
|
if (rideData['organizerUid'] != callerUid) {
|
||||||
rideData['organizerUid'] != null &&
|
|
||||||
rideData['organizerUid'] != callerUid) {
|
|
||||||
throw StateError('Only the organizer can detach a route.');
|
throw StateError('Only the organizer can detach a route.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,6 +415,7 @@ class RouteService {
|
||||||
if (routeSnap.exists) {
|
if (routeSnap.exists) {
|
||||||
batch.update(routeRef, {
|
batch.update(routeRef, {
|
||||||
'attachedGroupRideId': FieldValue.delete(),
|
'attachedGroupRideId': FieldValue.delete(),
|
||||||
|
'visibility': RouteVisibility.private.name,
|
||||||
'updatedAt': now.toIso8601String(),
|
'updatedAt': now.toIso8601String(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ void main() {
|
||||||
|
|
||||||
// Verify stop is added in list
|
// Verify stop is added in list
|
||||||
expect(find.text('Alice\'s Restaurant'), findsOneWidget);
|
expect(find.text('Alice\'s Restaurant'), findsOneWidget);
|
||||||
expect(find.text('1/8 STOPS'), findsOneWidget);
|
expect(find.text('1/25 STOPS'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('RoutePlannerScreen responsive layout on narrow 320dp screen', (tester) async {
|
testWidgets('RoutePlannerScreen responsive layout on narrow 320dp screen', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,144 @@
|
||||||
|
// ignore_for_file: subtype_of_sealed_class
|
||||||
|
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/navigation_handoff.dart';
|
import 'package:motorcycle_dash/services/routes/navigation_handoff.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_service.dart';
|
import 'package:motorcycle_dash/services/routes/route_service.dart';
|
||||||
|
|
||||||
void main() {
|
class FakeFirestore extends Fake implements FirebaseFirestore {
|
||||||
group('RouteService validation tests', () {
|
final Map<String, Map<String, dynamic>> routes = {};
|
||||||
final service = RouteService(firestore: null);
|
final Map<String, Map<String, dynamic>> groupRides = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
CollectionReference<Map<String, dynamic>> collection(String collectionPath) {
|
||||||
|
return FakeCollectionReference(this, collectionPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
WriteBatch batch() => FakeWriteBatch(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCollectionReference extends Fake
|
||||||
|
implements CollectionReference<Map<String, dynamic>> {
|
||||||
|
@override
|
||||||
|
final FakeFirestore firestore;
|
||||||
|
@override
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
FakeCollectionReference(this.firestore, this.path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
DocumentReference<Map<String, dynamic>> doc([String? docId]) {
|
||||||
|
return FakeDocumentReference(firestore, path, docId ?? 'auto_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocumentReference extends Fake
|
||||||
|
implements DocumentReference<Map<String, dynamic>> {
|
||||||
|
@override
|
||||||
|
final FakeFirestore firestore;
|
||||||
|
final String collectionPath;
|
||||||
|
final String docId;
|
||||||
|
|
||||||
|
FakeDocumentReference(this.firestore, this.collectionPath, this.docId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => docId;
|
||||||
|
|
||||||
|
Map<String, Map<String, dynamic>> get _store =>
|
||||||
|
collectionPath == 'routes' ? firestore.routes : firestore.groupRides;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> set(Map<String, dynamic> data, [SetOptions? options]) async {
|
||||||
|
_store[docId] = Map<String, dynamic>.from(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DocumentSnapshot<Map<String, dynamic>>> get([GetOptions? options]) async {
|
||||||
|
final data = _store[docId];
|
||||||
|
return FakeDocumentSnapshot(this, docId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> update(Map<Object, Object?> data) async {
|
||||||
|
final existing = _store[docId] ?? {};
|
||||||
|
final updated = Map<String, dynamic>.from(existing);
|
||||||
|
data.forEach((k, v) {
|
||||||
|
if (v == FieldValue.delete()) {
|
||||||
|
updated.remove(k.toString());
|
||||||
|
} else {
|
||||||
|
updated[k.toString()] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_store[docId] = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete() async {
|
||||||
|
_store.remove(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocumentSnapshot extends Fake
|
||||||
|
implements DocumentSnapshot<Map<String, dynamic>> {
|
||||||
|
final DocumentReference<Map<String, dynamic>> _ref;
|
||||||
|
final String _id;
|
||||||
|
final Map<String, dynamic>? _data;
|
||||||
|
|
||||||
|
FakeDocumentSnapshot(this._ref, this._id, this._data);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => _id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get exists => _data != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? data() =>
|
||||||
|
_data != null ? Map<String, dynamic>.from(_data) : null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DocumentReference<Map<String, dynamic>> get reference => _ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWriteBatch extends Fake implements WriteBatch {
|
||||||
|
final FakeFirestore firestore;
|
||||||
|
final List<Future<void> Function()> operations = [];
|
||||||
|
|
||||||
|
FakeWriteBatch(this.firestore);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void update(DocumentReference<Object?> document, Map<Object, Object?> data) {
|
||||||
|
operations.add(() async {
|
||||||
|
final doc = document as FakeDocumentReference;
|
||||||
|
await doc.update(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void delete(DocumentReference<Object?> document) {
|
||||||
|
operations.add(() async {
|
||||||
|
final doc = document as FakeDocumentReference;
|
||||||
|
await doc.delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> commit() async {
|
||||||
|
for (final op in operations) {
|
||||||
|
await op();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('RouteService stop validation tests', () {
|
||||||
|
test('validates route title length (minimum 3 characters)', () {
|
||||||
|
final fakeDb = FakeFirestore();
|
||||||
|
final service = RouteService(firestore: fakeDb);
|
||||||
|
|
||||||
test('validates route title length (minimum 3 characters)', () async {
|
|
||||||
expect(
|
expect(
|
||||||
() => service.saveRoute(
|
() => service.saveRoute(
|
||||||
title: 'Hi',
|
title: 'Hi',
|
||||||
|
|
@ -23,8 +153,11 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates route title length (maximum 80 characters)', () async {
|
test('validates route title length (maximum 80 characters)', () {
|
||||||
|
final fakeDb = FakeFirestore();
|
||||||
|
final service = RouteService(firestore: fakeDb);
|
||||||
final longTitle = 'A' * 81;
|
final longTitle = 'A' * 81;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => service.saveRoute(
|
() => service.saveRoute(
|
||||||
title: longTitle,
|
title: longTitle,
|
||||||
|
|
@ -39,7 +172,7 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates maximum stops technical cap (25 stops)', () async {
|
test('validates maximum stops technical cap (25 stops)', () {
|
||||||
final stops = List.generate(
|
final stops = List.generate(
|
||||||
26,
|
26,
|
||||||
(i) => RouteStop(
|
(i) => RouteStop(
|
||||||
|
|
@ -53,11 +186,7 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => service.saveRoute(
|
() => RouteService.validateStops(stops),
|
||||||
title: 'Too Many Stops Route',
|
|
||||||
ownerUid: 'alice',
|
|
||||||
stops: stops,
|
|
||||||
),
|
|
||||||
throwsA(isA<ArgumentError>().having(
|
throwsA(isA<ArgumentError>().having(
|
||||||
(e) => e.message,
|
(e) => e.message,
|
||||||
'message',
|
'message',
|
||||||
|
|
@ -66,7 +195,7 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates stop coordinates bounds', () async {
|
test('validates stop coordinates bounds', () {
|
||||||
final invalidLatStop = [
|
final invalidLatStop = [
|
||||||
const RouteStop(
|
const RouteStop(
|
||||||
id: 'stop-bad-lat',
|
id: 'stop-bad-lat',
|
||||||
|
|
@ -77,13 +206,8 @@ void main() {
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => service.saveRoute(
|
() => RouteService.validateStops(invalidLatStop),
|
||||||
title: 'Invalid Coordinates Route',
|
|
||||||
ownerUid: 'alice',
|
|
||||||
stops: invalidLatStop,
|
|
||||||
),
|
|
||||||
throwsA(isA<ArgumentError>().having(
|
throwsA(isA<ArgumentError>().having(
|
||||||
(e) => e.message,
|
(e) => e.message,
|
||||||
'message',
|
'message',
|
||||||
|
|
@ -101,13 +225,8 @@ void main() {
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => service.saveRoute(
|
() => RouteService.validateStops(invalidLngStop),
|
||||||
title: 'Invalid Coordinates Route',
|
|
||||||
ownerUid: 'alice',
|
|
||||||
stops: invalidLngStop,
|
|
||||||
),
|
|
||||||
throwsA(isA<ArgumentError>().having(
|
throwsA(isA<ArgumentError>().having(
|
||||||
(e) => e.message,
|
(e) => e.message,
|
||||||
'message',
|
'message',
|
||||||
|
|
@ -116,41 +235,572 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('offline fallback: saveRoute generates valid route object and reindexes stops', () async {
|
test('validates stop ID non-empty and bounded length', () {
|
||||||
final inputStops = [
|
expect(
|
||||||
const RouteStop(
|
() => RouteService.validateStops([
|
||||||
id: 'stop-b',
|
const RouteStop(
|
||||||
latitude: 37.4,
|
id: '',
|
||||||
longitude: -122.2,
|
latitude: 37.0,
|
||||||
displayName: 'Midpoint',
|
longitude: -122.0,
|
||||||
stopType: RouteStopType.waypoint,
|
displayName: 'Valid',
|
||||||
orderIndex: 99,
|
stopType: RouteStopType.origin,
|
||||||
),
|
),
|
||||||
const RouteStop(
|
]),
|
||||||
id: 'stop-a',
|
throwsA(isA<ArgumentError>().having(
|
||||||
latitude: 37.7,
|
(e) => e.message,
|
||||||
longitude: -122.4,
|
'message',
|
||||||
displayName: 'Start',
|
contains('missing an ID'),
|
||||||
stopType: RouteStopType.origin,
|
)),
|
||||||
orderIndex: 0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final saved = await service.saveRoute(
|
|
||||||
title: 'Skyline Ridge',
|
|
||||||
description: 'Twisty Sunday Run',
|
|
||||||
ownerUid: 'alice',
|
|
||||||
visibility: RouteVisibility.groupOnly,
|
|
||||||
stops: inputStops,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(saved.id, startsWith('route_'));
|
expect(
|
||||||
expect(saved.title, equals('Skyline Ridge'));
|
() => RouteService.validateStops([
|
||||||
expect(saved.ownerUid, equals('alice'));
|
RouteStop(
|
||||||
expect(saved.visibility, equals(RouteVisibility.groupOnly));
|
id: 'x' * 65,
|
||||||
expect(saved.stops.length, equals(2));
|
latitude: 37.0,
|
||||||
expect(saved.stops[0].orderIndex, equals(0));
|
longitude: -122.0,
|
||||||
expect(saved.stops[1].orderIndex, equals(1));
|
displayName: 'Valid',
|
||||||
|
stopType: RouteStopType.origin,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
throwsA(isA<ArgumentError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('exceeds 64 characters'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates duplicate stop IDs', () {
|
||||||
|
final duplicateStops = [
|
||||||
|
const RouteStop(
|
||||||
|
id: 'same-id',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'Stop 1',
|
||||||
|
),
|
||||||
|
const RouteStop(
|
||||||
|
id: 'same-id',
|
||||||
|
latitude: 37.1,
|
||||||
|
longitude: -122.1,
|
||||||
|
displayName: 'Stop 2',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
() => RouteService.validateStops(duplicateStops),
|
||||||
|
throwsA(isA<ArgumentError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Duplicate route stop ID'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates stop notes and address bounding', () {
|
||||||
|
expect(
|
||||||
|
() => RouteService.validateStops([
|
||||||
|
RouteStop(
|
||||||
|
id: 'stop-long-notes',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'Stop',
|
||||||
|
notes: 'A' * 501,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
throwsA(isA<ArgumentError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('exceed 500 characters'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => RouteService.validateStops([
|
||||||
|
RouteStop(
|
||||||
|
id: 'stop-long-addr',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'Stop',
|
||||||
|
address: 'B' * 201,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
throwsA(isA<ArgumentError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('exceed 200 characters'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('group-only visibility requires attached group ride', () {
|
||||||
|
final fakeDb = FakeFirestore();
|
||||||
|
final service = RouteService(firestore: fakeDb);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.saveRoute(
|
||||||
|
title: 'Secret Group Route',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
visibility: RouteVisibility.groupOnly,
|
||||||
|
attachedGroupRideId: null,
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'S1',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Group-only route visibility requires'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Requirement 9: Explicit Failure when Persistence Unavailable', () {
|
||||||
|
final unconfiguredService = RouteService(firestore: null);
|
||||||
|
|
||||||
|
test('saveRoute fails explicitly when persistence is unconfigured', () async {
|
||||||
|
await expectLater(
|
||||||
|
() => unconfiguredService.saveRoute(
|
||||||
|
title: 'Skyline Ridge',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'Stop 1',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('persistence unavailable'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateRoute fails explicitly when persistence is unconfigured', () async {
|
||||||
|
final route = MotorSignalRoute(
|
||||||
|
id: 'r1',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Title',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
() => unconfiguredService.updateRoute(route: route, callerUid: 'alice'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('persistence unavailable'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteRoute fails explicitly when persistence is unconfigured', () async {
|
||||||
|
await expectLater(
|
||||||
|
() => unconfiguredService.deleteRoute('r1', callerUid: 'alice'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('persistence unavailable'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachRouteToGroupRide fails explicitly when persistence is unconfigured', () async {
|
||||||
|
await expectLater(
|
||||||
|
() => unconfiguredService.attachRouteToGroupRide(
|
||||||
|
routeId: 'r1',
|
||||||
|
groupRideId: 'g1',
|
||||||
|
callerUid: 'alice',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('persistence unavailable'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detachRouteFromGroupRide fails explicitly when persistence is unconfigured', () async {
|
||||||
|
await expectLater(
|
||||||
|
() => unconfiguredService.detachRouteFromGroupRide('g1', callerUid: 'alice'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('persistence unavailable'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Requirement 5 & 10: Service Authority and Provenance Integrity', () {
|
||||||
|
late FakeFirestore fakeDb;
|
||||||
|
late RouteService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fakeDb = FakeFirestore();
|
||||||
|
service = RouteService(firestore: fakeDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create -> update: createdAt, ownerUid, id unchanged; updatedAt advances', () async {
|
||||||
|
final originalCreatedAt = DateTime.utc(2026, 9, 4, 10, 0, 0);
|
||||||
|
|
||||||
|
// Save initial route with explicit creation time
|
||||||
|
final initial = await service.saveRoute(
|
||||||
|
id: 'route_preserve_prov',
|
||||||
|
title: 'Initial Title',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
createdAt: originalCreatedAt,
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.7,
|
||||||
|
longitude: -122.4,
|
||||||
|
displayName: 'Start',
|
||||||
|
),
|
||||||
|
const RouteStop(
|
||||||
|
id: 's2',
|
||||||
|
latitude: 37.3,
|
||||||
|
longitude: -121.8,
|
||||||
|
displayName: 'End',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initial.createdAt, equals(originalCreatedAt));
|
||||||
|
expect(initial.ownerUid, equals('alice'));
|
||||||
|
expect(initial.id, equals('route_preserve_prov'));
|
||||||
|
|
||||||
|
// Wait 5ms so DateTime.now() strictly advances
|
||||||
|
await Future.delayed(const Duration(milliseconds: 5));
|
||||||
|
|
||||||
|
final updated = await service.updateRoute(
|
||||||
|
route: initial.copyWith(
|
||||||
|
title: 'Updated Title',
|
||||||
|
),
|
||||||
|
callerUid: 'alice',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.id, equals('route_preserve_prov'));
|
||||||
|
expect(updated.ownerUid, equals('alice'));
|
||||||
|
expect(updated.title, equals('Updated Title'));
|
||||||
|
expect(updated.createdAt, equals(originalCreatedAt)); // Provenance strictly preserved!
|
||||||
|
expect(updated.updatedAt.isAfter(originalCreatedAt), isTrue); // Timestamp advances!
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing or empty caller identity is rejected for authority operations', () async {
|
||||||
|
final route = MotorSignalRoute(
|
||||||
|
id: 'r1',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
title: 'Title',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => service.updateRoute(route: route, callerUid: ''),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Caller identity required'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => service.deleteRoute('r1', callerUid: ''),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Caller identity required'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => service.attachRouteToGroupRide(
|
||||||
|
routeId: 'r1',
|
||||||
|
groupRideId: 'g1',
|
||||||
|
callerUid: '',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Caller identity required'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => service.detachRouteFromGroupRide('g1', callerUid: ''),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Caller identity required'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong route owner cannot update or delete route', () async {
|
||||||
|
final route = await service.saveRoute(
|
||||||
|
id: 'route_alice',
|
||||||
|
title: 'Alice Route',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.0,
|
||||||
|
longitude: -122.0,
|
||||||
|
displayName: 'S1',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob tries to update Alice's route -> DENIED
|
||||||
|
await expectLater(
|
||||||
|
() => service.updateRoute(route: route, callerUid: 'bob'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Only the route owner can update'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob tries to delete Alice's route -> DENIED
|
||||||
|
await expectLater(
|
||||||
|
() => service.deleteRoute('route_alice', callerUid: 'bob'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Only the route owner can delete'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Requirement 6 & 7: Attachment Ownership Policy and Safe Detach', () {
|
||||||
|
late FakeFirestore fakeDb;
|
||||||
|
late RouteService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fakeDb = FakeFirestore();
|
||||||
|
service = RouteService(firestore: fakeDb);
|
||||||
|
|
||||||
|
// Seed group ride organized by Alice
|
||||||
|
fakeDb.groupRides['ride_alice'] = {
|
||||||
|
'id': 'ride_alice',
|
||||||
|
'organizerUid': 'alice',
|
||||||
|
'title': 'Alice Group Ride',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('organizer + owned route -> ALLOW attach', () async {
|
||||||
|
final route = await service.saveRoute(
|
||||||
|
id: 'route_alice',
|
||||||
|
title: 'Alice Route',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.7,
|
||||||
|
longitude: -122.4,
|
||||||
|
displayName: 'SF',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.attachRouteToGroupRide(
|
||||||
|
route: route,
|
||||||
|
groupRideId: 'ride_alice',
|
||||||
|
callerUid: 'alice',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verified attached in Group Ride
|
||||||
|
expect(fakeDb.groupRides['ride_alice']?['routeId'], equals('route_alice'));
|
||||||
|
expect(fakeDb.groupRides['ride_alice']?['routeTitle'], equals('Alice Route'));
|
||||||
|
|
||||||
|
// Verified attached in Route doc with groupOnly visibility
|
||||||
|
expect(fakeDb.routes['route_alice']?['attachedGroupRideId'], equals('ride_alice'));
|
||||||
|
expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.groupOnly.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('organizer + someone else private route -> DENY attach', () async {
|
||||||
|
await service.saveRoute(
|
||||||
|
id: 'route_bob',
|
||||||
|
title: 'Bob Private Route',
|
||||||
|
ownerUid: 'bob',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.7,
|
||||||
|
longitude: -122.4,
|
||||||
|
displayName: 'SF',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice is organizer of ride_alice, but tries to attach Bob's route -> DENIED
|
||||||
|
await expectLater(
|
||||||
|
() => service.attachRouteToGroupRide(
|
||||||
|
routeId: 'route_bob',
|
||||||
|
groupRideId: 'ride_alice',
|
||||||
|
callerUid: 'alice',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Organizer can only attach a route they own'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary participant + any route -> DENY attach', () async {
|
||||||
|
final route = await service.saveRoute(
|
||||||
|
id: 'route_bob',
|
||||||
|
title: 'Bob Route',
|
||||||
|
ownerUid: 'bob',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.7,
|
||||||
|
longitude: -122.4,
|
||||||
|
displayName: 'SF',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob is participant, not organizer of ride_alice -> DENIED
|
||||||
|
await expectLater(
|
||||||
|
() => service.attachRouteToGroupRide(
|
||||||
|
route: route,
|
||||||
|
groupRideId: 'ride_alice',
|
||||||
|
callerUid: 'bob',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Only the Group Ride organizer can attach a route'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safe detach: organizer authority clears group ride and restores route to private', () async {
|
||||||
|
// 1. Setup attached route
|
||||||
|
final route = await service.saveRoute(
|
||||||
|
id: 'route_alice',
|
||||||
|
title: 'Alice Route',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
stops: [
|
||||||
|
const RouteStop(
|
||||||
|
id: 's1',
|
||||||
|
latitude: 37.7,
|
||||||
|
longitude: -122.4,
|
||||||
|
displayName: 'SF',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await service.attachRouteToGroupRide(
|
||||||
|
route: route,
|
||||||
|
groupRideId: 'ride_alice',
|
||||||
|
callerUid: 'alice',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Non-organizer Bob attempts detach -> DENIED
|
||||||
|
await expectLater(
|
||||||
|
() => service.detachRouteFromGroupRide('ride_alice', callerUid: 'bob'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Only the organizer can detach'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Organizer Alice detaches route -> ALLOWED
|
||||||
|
await service.detachRouteFromGroupRide('ride_alice', callerUid: 'alice');
|
||||||
|
|
||||||
|
// Group Ride route fields deleted
|
||||||
|
expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse);
|
||||||
|
expect(fakeDb.groupRides['ride_alice']?.containsKey('routeTitle'), isFalse);
|
||||||
|
|
||||||
|
// Route attachedGroupRideId cleared and visibility restored to private
|
||||||
|
expect(fakeDb.routes['route_alice']?.containsKey('attachedGroupRideId'), isFalse);
|
||||||
|
expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.private.name));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Requirement 8: Safe Delete of Attached Route', () {
|
||||||
|
late FakeFirestore fakeDb;
|
||||||
|
late RouteService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fakeDb = FakeFirestore();
|
||||||
|
service = RouteService(firestore: fakeDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot delete attached route if caller is not the group organizer', () async {
|
||||||
|
// Bob organizes group ride
|
||||||
|
fakeDb.groupRides['ride_bob'] = {
|
||||||
|
'id': 'ride_bob',
|
||||||
|
'organizerUid': 'bob',
|
||||||
|
'title': 'Bob Ride',
|
||||||
|
'routeId': 'route_alice',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice owns route attached to Bob's group ride
|
||||||
|
fakeDb.routes['route_alice'] = {
|
||||||
|
'id': 'route_alice',
|
||||||
|
'ownerUid': 'alice',
|
||||||
|
'title': 'Alice Route',
|
||||||
|
'attachedGroupRideId': 'ride_bob',
|
||||||
|
'visibility': 'groupOnly',
|
||||||
|
'stops': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice attempts to delete route without organizer authority to detach from ride_bob -> REFUSED
|
||||||
|
await expectLater(
|
||||||
|
() => service.deleteRoute('route_alice', callerUid: 'alice'),
|
||||||
|
throwsA(isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('Cannot delete a route attached to a Group Ride you do not organize'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route remains in database (not dangling)
|
||||||
|
expect(fakeDb.routes.containsKey('route_alice'), isTrue);
|
||||||
|
expect(fakeDb.groupRides['ride_bob']?['routeId'], equals('route_alice'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomic delete of attached route succeeds when caller is route owner and organizer', () async {
|
||||||
|
// Alice organizes group ride
|
||||||
|
fakeDb.groupRides['ride_alice'] = {
|
||||||
|
'id': 'ride_alice',
|
||||||
|
'organizerUid': 'alice',
|
||||||
|
'title': 'Alice Ride',
|
||||||
|
'routeId': 'route_alice',
|
||||||
|
'routeTitle': 'Alice Route',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice owns route attached to her own group ride
|
||||||
|
fakeDb.routes['route_alice'] = {
|
||||||
|
'id': 'route_alice',
|
||||||
|
'ownerUid': 'alice',
|
||||||
|
'title': 'Alice Route',
|
||||||
|
'attachedGroupRideId': 'ride_alice',
|
||||||
|
'visibility': 'groupOnly',
|
||||||
|
'stops': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice deletes route -> atomic batch clears group ride route reference and deletes route
|
||||||
|
await service.deleteRoute('route_alice', callerUid: 'alice');
|
||||||
|
|
||||||
|
expect(fakeDb.routes.containsKey('route_alice'), isFalse);
|
||||||
|
expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue