fix(website): persisted-only ride creation and reconciled replay timing (PR #38 hardening)

Phase 1 — group ride creation (no phantom rides):
- Check authentication and Firestore availability BEFORE touching UI state; an unauthenticated
  user (or unavailable Firestore) now gets an actionable error and no phantom ride, instead of a
  local entry that was never saved.
- Insert the ride into UI state ONLY after batch.commit() confirms the write; on commit failure
  nothing is inserted and an error is shown.
- Prevent duplicate submissions: the create modal disables its submit button while in flight, and
  the hub guards with a ref.
- The join code remains the real Firestore document id (no fabricated ids).
- Extract commitGroupRide (pure, dependency-injected) so every path is unit-testable.

Phase 2 — replay playback timing:
- The elapsed clock and rider marker both derive from the same cursor, so they cannot drift.
- Manual seeks (scrub, restart, corner jump, arrow keys) go through seekTo(), which reconciles
  playback timing (clears carry/last-frame) so no stale budget is applied after a seek.
- Make gap compression explicit: a caption labels the timeline as recorded elapsed time and warns
  that dead time in multi-segment recordings is skipped during playback.

Tests: commitGroupRide (unauthenticated / unavailable / commit-ok / commit-failed); group-rides
hub now uses an authenticated mock with deterministic Firebase — authenticated create persists,
unauthenticated create is blocked with no phantom, a failed commit leaves no phantom, and the
create modal blocks duplicate submits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Developer 2026-10-14 13:12:49 -04:00
parent 6956e809a3
commit dd35b216d1
6 changed files with 359 additions and 69 deletions

View File

@ -6,7 +6,7 @@ import { GroupRideSummary } from './types';
interface CreateRideModalProps {
isOpen: boolean;
onClose: () => void;
onCreateRide: (ride: GroupRideSummary) => void;
onCreateRide: (ride: GroupRideSummary) => void | Promise<void>;
}
export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
@ -24,6 +24,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
const [participantLimit, setParticipantLimit] = useState(12);
const [description, setDescription] = useState('');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when modal opens
useEffect(() => {
@ -42,6 +43,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
setParticipantLimit(12);
setDescription('');
setErrorMsg(null);
setIsSubmitting(false);
}
}, [isOpen]);
@ -58,9 +60,12 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent duplicate submissions while a create is in flight.
if (isSubmitting) return;
if (!title.trim()) {
setErrorMsg('Please enter a ride title.');
return;
@ -92,8 +97,13 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
status: 'scheduled',
};
onCreateRide(newRide);
setIsSubmitting(true);
try {
await onCreateRide(newRide);
onClose();
} finally {
setIsSubmitting(false);
}
};
return (
@ -349,9 +359,10 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
</button>
<button
type="submit"
className="px-space-xl py-2.5 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
disabled={isSubmitting}
className="px-space-xl py-2.5 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
Publish Group Ride
{isSubmitting ? 'Publishing…' : 'Publish Group Ride'}
</button>
</div>
</form>

View File

@ -1,6 +1,6 @@
'use client';
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import Link from 'next/link';
import { getApps, initializeApp } from 'firebase/app';
import { getAuth, connectAuthEmulator } from 'firebase/auth';
@ -23,7 +23,7 @@ import {
connectFirestoreEmulator,
} from 'firebase/firestore';
import { firestore } from '../../lib/firebase-client';
import { buildWebGroupRidePayload } from '../../lib/group-ride-payload';
import { commitGroupRide } from '../../lib/create-group-ride';
function getActiveFirestore() {
if (
@ -725,6 +725,8 @@ export function GroupRidesManagement({
onViewShowcase,
}: GroupRidesManagementProps) {
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
// Guards against duplicate create submissions while a write is in flight.
const creatingRideRef = useRef(false);
const [communityRides, setCommunityRides] = useState<GroupRideSummary[]>([]);
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
const [communityError, setCommunityError] = useState<string | null>(null);
@ -1002,66 +1004,55 @@ export function GroupRidesManagement({
// Handlers for Add, Alter, Remove, Join, Leave
const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string; clubName?: string }) => {
// Prevent duplicate submissions while a create is already in flight.
if (creatingRideRef.current) return;
const uid = currentUser?.uid;
const now = new Date();
const rideId = uid
? `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}`
: `local_${now.getTime()}`;
const rideWithOrganizer: GroupRideSummary = {
...newRide,
id: rideId,
isOrganizer: true,
status: 'scheduled',
userStatus: 'joined',
rideCode: rideId,
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
};
// Optimistic local insert — rolled back below if a backend write is attempted and fails.
setRides((prev) => [rideWithOrganizer, ...prev]);
// Without an authenticated session and Firestore we cannot persist the ride. Keep the
// local entry for responsiveness, but never claim it was saved to the cloud.
// Check authentication and persistence availability BEFORE touching UI state, so an
// unauthenticated user (or an unavailable Firestore) never sees a phantom ride that was
// never saved. They get an actionable error instead.
if (!uid || !firestore) {
showAlert('Sign in to create a group ride — it could not be saved.', 'error');
return;
}
creatingRideRef.current = true;
try {
const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer';
const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider';
const firestoreRideData = buildWebGroupRidePayload(newRide, {
rideId,
const result = await commitGroupRide(newRide, {
uid,
leadHandle,
firestore,
leadDisplayName,
now,
leadHandle,
writeBatch: (fs) => writeBatch(fs),
docRef: (fs, ...segments) => doc(fs, segments[0], ...segments.slice(1)),
});
const organizerParticipant = {
userId: uid,
role: 'organizer',
status: 'accepted',
riderHandle: leadHandle,
riderDisplayName: leadDisplayName,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
if (result.ok) {
// Insert into UI state ONLY after Firestore confirms the write — no phantom rides.
const rideWithOrganizer: GroupRideSummary = {
...newRide,
id: result.rideId,
isOrganizer: true,
status: 'scheduled',
userStatus: 'joined',
rideCode: result.rideId,
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
};
const batch = writeBatch(firestore);
batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData);
batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant);
await batch.commit();
// Report success only after the write actually commits.
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success');
} catch (err: any) {
console.error('Failed to persist group ride to Firestore:', err);
// Roll back the optimistic insert and report the failure truthfully.
setRides((prev) => prev.filter((r) => r.id !== rideId));
setRides((prev) => (prev.some((r) => r.id === result.rideId) ? prev : [rideWithOrganizer, ...prev]));
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${result.rideId}`, 'success');
} else if (result.reason === 'commit-failed') {
console.error('Failed to persist group ride to Firestore:', result.error);
showAlert('Could not create the ride. Please check your connection and try again.', 'error');
} else {
// unauthenticated / unavailable discovered inside the helper (defensive — guarded above).
showAlert('Could not create the ride right now. Please try again.', 'error');
}
} finally {
creatingRideRef.current = false;
}
};

View File

@ -131,6 +131,26 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
indexRef.current = currentIndex;
}, [currentIndex]);
// True when the recording has disconnected segments / GPS-loss gaps. Playback compresses
// those gaps (advancePlaybackIndex), so the elapsed clock — which shows real recorded time —
// will jump forward at a gap even though wall-clock playback does not pause. We surface this
// explicitly rather than let the jump look like a glitch.
const hasCompressedGaps = useMemo(
() => points.some((p, i) => i > 0 && !areTelemetryPointsConnected(points[i - 1], p)),
[points]
);
// Manual seek (scrub, restart, corner jump, arrow keys): reconcile playback timing so the
// next animation frame does not apply stale budget accumulated before the seek.
const seekTo = (index: number) => {
const maxIndex = Math.max(0, totalPoints - 1);
const clamped = Math.max(0, Math.min(maxIndex, index));
playbackCarryRef.current = 0;
lastTimeRef.current = null;
indexRef.current = clamped;
setCurrentIndex(clamped);
};
// Dynamically load Leaflet library and CSS with multi-CDN fallback
useEffect(() => {
if (typeof window === 'undefined') return;
@ -443,7 +463,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
{ direction: 'top' }
);
cornerMarker.on('click', () => {
setCurrentIndex(corner.apexIndex);
seekTo(corner.apexIndex);
});
routeGroup.addLayer(cornerMarker);
}
@ -666,9 +686,9 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
e.preventDefault();
setIsPlaying((prev) => !prev);
} else if (e.code === 'ArrowRight' && totalPoints > 0) {
setCurrentIndex((prev) => Math.min(totalPoints - 1, prev + 20));
seekTo(indexRef.current + 20);
} else if (e.code === 'ArrowLeft') {
setCurrentIndex((prev) => Math.max(0, prev - 20));
seekTo(indexRef.current - 20);
} else if (e.code === 'Escape') {
onClose();
}
@ -1064,7 +1084,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
key={corner.id}
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
onClick={() => {
setCurrentIndex(corner.apexIndex);
seekTo(corner.apexIndex);
setActiveTab('map');
}}
>
@ -1105,7 +1125,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex(corner.apexIndex);
seekTo(corner.apexIndex);
setActiveTab('map');
}}
className="px-2 py-1 rounded bg-surface-container-high hover:bg-brand-cyan hover:text-black text-[10px] transition cursor-pointer"
@ -1140,7 +1160,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
min={0}
max={Math.max(1, totalPoints - 1)}
value={currentIndex}
onChange={(e) => setCurrentIndex(Number(e.target.value))}
onChange={(e) => seekTo(Number(e.target.value))}
className="flex-1 accent-brand-cyan cursor-pointer h-2 bg-slate-800 rounded-lg"
data-testid="replay-timeline-slider"
disabled={!points.length}
@ -1148,11 +1168,22 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
</div>
{/* Timeline is recorded time. Make the gap-compression behaviour explicit. */}
<div className="flex items-center justify-between text-[10px] font-mono text-slate-500">
<span>Recorded elapsed time</span>
{hasCompressedGaps && (
<span className="text-amber-400/80 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">timer_off</span>
Recording has gaps — dead time is skipped during playback
</span>
)}
</div>
{/* Transport Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentIndex(0)}
onClick={() => seekTo(0)}
title="Restart from beginning"
className="p-2 rounded-lg bg-surface-container hover:bg-slate-700 text-slate-300 hover:text-white transition cursor-pointer"
>

View File

@ -0,0 +1,78 @@
import { buildWebGroupRidePayload, type WebGroupRideInput } from './group-ride-payload';
export type CreateRideResult =
| { ok: true; rideId: string; payload: Record<string, unknown> }
| { ok: false; reason: 'unauthenticated' }
| { ok: false; reason: 'unavailable' }
| { ok: false; reason: 'commit-failed'; error: unknown };
export interface FirestoreBatchLike {
// Loose signatures so the real firebase WriteBatch is assignable here (its set() is
// generic over DocumentReference and returns WriteBatch). Tests pass a simple fake.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
set: (ref: any, data: any) => unknown;
commit: () => Promise<void>;
}
export interface CommitGroupRideDeps {
uid?: string | null;
firestore: unknown | null;
leadDisplayName: string;
leadHandle: string;
/** Injected Firestore primitives so this is unit-testable without the firebase SDK. */
writeBatch: (firestore: any) => FirestoreBatchLike;
docRef: (firestore: any, ...segments: string[]) => unknown;
now?: Date;
}
/**
* Persists a web-created group ride and reports the outcome. It performs the backend write
* and resolves ONLY after the batch commit succeeds, so callers can insert into UI state
* strictly after confirmation (no phantom rides). It never fabricates coordinates (see
* buildWebGroupRidePayload) and the returned rideId is the real Firestore document id used
* as the join code.
*
* Returns a discriminated result instead of throwing, so every failure path is explicit:
* - unauthenticated: no uid (an unauthenticated user cannot create a persisted ride)
* - unavailable: no Firestore handle
* - commit-failed: the write was attempted but rejected (nothing persisted)
*/
export async function commitGroupRide(
newRide: WebGroupRideInput,
deps: CommitGroupRideDeps
): Promise<CreateRideResult> {
const uid = deps.uid;
if (!uid) return { ok: false, reason: 'unauthenticated' };
if (!deps.firestore) return { ok: false, reason: 'unavailable' };
const now = deps.now ?? new Date();
const rideId = `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}`;
const payload = buildWebGroupRidePayload(newRide, {
rideId,
uid,
leadHandle: deps.leadHandle,
leadDisplayName: deps.leadDisplayName,
now,
});
const organizerParticipant = {
userId: uid,
role: 'organizer',
status: 'accepted',
riderHandle: deps.leadHandle,
riderDisplayName: deps.leadDisplayName,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
try {
const batch = deps.writeBatch(deps.firestore);
batch.set(deps.docRef(deps.firestore, 'group_rides', rideId), payload);
batch.set(deps.docRef(deps.firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant);
await batch.commit();
return { ok: true, rideId, payload };
} catch (error) {
return { ok: false, reason: 'commit-failed', error };
}
}

View File

@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
import { commitGroupRide } from '../lib/create-group-ride';
import type { WebGroupRideInput } from '../lib/group-ride-payload';
const baseRide: WebGroupRideInput = {
id: 'ride-temp',
title: 'Canyon Dawn Patrol',
description: 'Fast sweepers',
region: 'Santa Cruz Mountains',
scheduledDate: '',
scheduledTime: '',
paceClassification: 'Brisk Pace',
discipline: 'Sport / Canyon',
formationProfile: 'Staggered Dual Column [B]',
joinPolicy: 'requestToJoin',
leadHandle: 'You (Organizer)',
leadAvatarText: 'YOU',
participantCount: 1,
participantLimit: 12,
distance: '50.0 Miles',
meetingPoint: 'Alice’s Restaurant',
routeSnippet: 'Skyline > Hwy 9',
userStatus: 'joined',
status: 'scheduled',
};
function makeDeps(overrides: Partial<Parameters<typeof commitGroupRide>[1]> = {}) {
const setSpy = vi.fn();
const commitSpy = vi.fn().mockResolvedValue(undefined);
const deps = {
uid: 'rider-uid-1' as string | null | undefined,
firestore: {} as unknown,
leadDisplayName: 'Rider One',
leadHandle: 'rider_one',
now: new Date('2026-10-14T12:00:00.000Z'),
writeBatch: () => ({ set: setSpy, commit: commitSpy }),
docRef: (_fs: any, ...segments: string[]) => ({ path: segments.join('/') }),
...overrides,
};
return { deps, setSpy, commitSpy };
}
describe('commitGroupRide (WEB Phase 1 — persisted ride creation)', () => {
it('refuses to create for an unauthenticated user (no uid) and never writes', async () => {
const { deps, commitSpy } = makeDeps({ uid: undefined });
const res = await commitGroupRide(baseRide, deps);
expect(res).toEqual({ ok: false, reason: 'unauthenticated' });
expect(commitSpy).not.toHaveBeenCalled();
});
it('reports unavailable when Firestore is missing and never writes', async () => {
const { deps, commitSpy } = makeDeps({ firestore: null });
const res = await commitGroupRide(baseRide, deps);
expect(res).toEqual({ ok: false, reason: 'unavailable' });
expect(commitSpy).not.toHaveBeenCalled();
});
it('persists the ride and participant, then returns the real rideId as the join code', async () => {
const { deps, setSpy, commitSpy } = makeDeps();
const res = await commitGroupRide(baseRide, deps);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.rideId).toMatch(/^ride_\d+_rider-/);
// Two writes: the ride document and the organizer participant.
expect(setSpy).toHaveBeenCalledTimes(2);
expect(commitSpy).toHaveBeenCalledTimes(1);
// The persisted payload never fabricates coordinates.
const mp = (res.payload as any).meetingPoint;
expect('latitude' in mp).toBe(false);
expect('longitude' in mp).toBe(false);
expect(mp.displayName).toBe('Alice’s Restaurant');
expect((res.payload as any).organizerUid).toBe('rider-uid-1');
});
it('returns commit-failed (and does not throw) when the Firestore write rejects', async () => {
const boom = new Error('network down');
const { deps } = makeDeps({
writeBatch: () => ({ set: vi.fn(), commit: vi.fn().mockRejectedValue(boom) }),
});
const res = await commitGroupRide(baseRide, deps);
expect(res).toEqual({ ok: false, reason: 'commit-failed', error: boom });
});
});

View File

@ -19,12 +19,56 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace, back: vi.fn() }),
}));
// Deterministic Firebase so the authenticated GroupRidesManagement hub renders and persists
// without real network. `fb.commitImpl` lets individual tests force commit success/failure.
const fb = vi.hoisted(() => ({ commitImpl: vi.fn(() => Promise.resolve()) }));
vi.mock('firebase/firestore', () => ({
collection: vi.fn(() => ({})),
query: vi.fn(() => ({})),
where: vi.fn(() => ({})),
onSnapshot: vi.fn(() => () => {}),
doc: vi.fn((_fs: unknown, ...segs: string[]) => ({ path: segs.join('/') })),
writeBatch: vi.fn(() => ({ set: vi.fn(), update: vi.fn(), commit: () => fb.commitImpl() })),
updateDoc: vi.fn(() => Promise.resolve()),
deleteDoc: vi.fn(() => Promise.resolve()),
setDoc: vi.fn(() => Promise.resolve()),
arrayUnion: vi.fn((x: unknown) => x),
arrayRemove: vi.fn((x: unknown) => x),
increment: vi.fn((n: unknown) => n),
serverTimestamp: vi.fn(() => 'ts'),
getDoc: vi.fn(() => Promise.resolve({ exists: () => false, data: () => ({}) })),
getFirestore: vi.fn(() => ({})),
connectFirestoreEmulator: vi.fn(),
}));
vi.mock('firebase/app', () => ({
getApps: vi.fn(() => []),
initializeApp: vi.fn(() => ({ name: 'test' })),
}));
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(() => ({ currentUser: null })),
connectAuthEmulator: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(() => Promise.resolve()),
onAuthStateChanged: vi.fn((_auth: unknown, cb: (u: unknown) => void) => {
cb(null);
return () => {};
}),
}));
vi.mock('../lib/firebase-client', () => ({ firestore: {}, firebaseAuth: { currentUser: null } }));
// An authenticated organizer: a real Firebase user always has a uid.
const authedUser = { uid: 'organizer-uid-1', email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
import GroupRidesManageRoute from '../app/group-rides/manage/page';
import GroupRidesShowcaseRoute from '../app/group-rides/showcase/page';
describe('Group Rides Interactive Page & Components', () => {
beforeEach(() => {
vi.restoreAllMocks();
// Default: Firestore commit succeeds. Individual tests override for failure paths.
fb.commitImpl.mockReset();
fb.commitImpl.mockResolvedValue(undefined);
});
// ============================================================
@ -178,9 +222,9 @@ describe('Group Rides Interactive Page & Components', () => {
expect(onCreateRide).not.toHaveBeenCalled();
});
it('submits valid ride and invokes onCreateRide callback', () => {
it('submits valid ride and invokes onCreateRide callback', async () => {
const onClose = vi.fn();
const onCreateRide = vi.fn();
const onCreateRide = vi.fn().mockResolvedValue(undefined);
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
@ -200,7 +244,31 @@ describe('Group Rides Interactive Page & Components', () => {
userStatus: 'joined',
})
);
expect(onClose).toHaveBeenCalled();
// The modal closes only after onCreateRide resolves (persistence confirmed).
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('disables the submit button while a create is in flight (no duplicate submissions)', async () => {
const onClose = vi.fn();
let resolveCreate: () => void = () => {};
const onCreateRide = vi.fn(() => new Promise<void>((r) => { resolveCreate = r; }));
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Pending Ride' } });
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
const submitBtn = screen.getByRole('button', { name: /publish group ride/i });
fireEvent.click(submitBtn);
// While the create promise is pending, the button is disabled and relabeled.
const pendingBtn = await screen.findByRole('button', { name: /publishing/i });
expect((pendingBtn as HTMLButtonElement).disabled).toBe(true);
// A second click must not trigger another create.
fireEvent.click(pendingBtn);
expect(onCreateRide).toHaveBeenCalledTimes(1);
resolveCreate();
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
@ -308,8 +376,7 @@ describe('Group Rides Interactive Page & Components', () => {
describe('GroupRidesManagement (For Logged In Users - View, Add, Remove, Alter)', () => {
it('allows viewing, adding, altering, canceling, and deleting group rides', async () => {
const mockUser = { email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
render(<GroupRidesManagement currentUser={mockUser} />);
render(<GroupRidesManagement currentUser={authedUser} />);
// Header & Console title
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
@ -348,8 +415,8 @@ describe('Group Rides Interactive Page & Components', () => {
});
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
// Verify newly created ride appears in the list
expect(screen.getByRole('heading', { name: /High Sierra Alpine Sprint/i })).toBeDefined();
// The ride is inserted only after Firestore confirms the write, so wait for it.
await screen.findByRole('heading', { name: /High Sierra Alpine Sprint/i });
// 3. ALTER: Edit existing organized ride parameters
const alterButtons = screen.getAllByRole('button', { name: /alter \/ edit/i });
@ -394,6 +461,34 @@ describe('Group Rides Interactive Page & Components', () => {
expect(screen.getByText(/Live Squad Telemetry Stream: Connected/i)).toBeDefined();
expect(screen.getByText(/Formation session #CA-84-SW/i)).toBeDefined();
});
it('blocks ride creation for an unauthenticated user and never shows a phantom ride', async () => {
const anonUser = { email: 'anon@motorsignal.app' } as any; // no uid
render(<GroupRidesManagement currentUser={anonUser} />);
fireEvent.click(screen.getByRole('button', { name: /create group ride/i }));
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Phantom Ride' } });
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
// Actionable error, and the ride must never appear in the list.
await screen.findByText(/could not be saved/i);
expect(screen.queryByRole('heading', { name: /Phantom Ride/i })).toBeNull();
});
it('does not leave a phantom ride when the Firestore commit fails', async () => {
fb.commitImpl.mockReset();
fb.commitImpl.mockRejectedValue(new Error('offline'));
render(<GroupRidesManagement currentUser={authedUser} />);
fireEvent.click(screen.getByRole('button', { name: /create group ride/i }));
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Doomed Ride' } });
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
await screen.findByText(/Could not create the ride/i);
expect(screen.queryByRole('heading', { name: /Doomed Ride/i })).toBeNull();
});
});
describe('Full GroupRidesPage Smart Routing', () => {