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:
parent
6956e809a3
commit
dd35b216d1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue