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 {
|
interface CreateRideModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateRide: (ride: GroupRideSummary) => void;
|
onCreateRide: (ride: GroupRideSummary) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
||||||
|
|
@ -24,6 +24,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
||||||
const [participantLimit, setParticipantLimit] = useState(12);
|
const [participantLimit, setParticipantLimit] = useState(12);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,6 +43,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
||||||
setParticipantLimit(12);
|
setParticipantLimit(12);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
|
@ -58,9 +60,12 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Prevent duplicate submissions while a create is in flight.
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
setErrorMsg('Please enter a ride title.');
|
setErrorMsg('Please enter a ride title.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -92,8 +97,13 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
};
|
};
|
||||||
|
|
||||||
onCreateRide(newRide);
|
setIsSubmitting(true);
|
||||||
onClose();
|
try {
|
||||||
|
await onCreateRide(newRide);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -349,9 +359,10 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getApps, initializeApp } from 'firebase/app';
|
import { getApps, initializeApp } from 'firebase/app';
|
||||||
import { getAuth, connectAuthEmulator } from 'firebase/auth';
|
import { getAuth, connectAuthEmulator } from 'firebase/auth';
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
connectFirestoreEmulator,
|
connectFirestoreEmulator,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { firestore } from '../../lib/firebase-client';
|
import { firestore } from '../../lib/firebase-client';
|
||||||
import { buildWebGroupRidePayload } from '../../lib/group-ride-payload';
|
import { commitGroupRide } from '../../lib/create-group-ride';
|
||||||
|
|
||||||
function getActiveFirestore() {
|
function getActiveFirestore() {
|
||||||
if (
|
if (
|
||||||
|
|
@ -725,6 +725,8 @@ export function GroupRidesManagement({
|
||||||
onViewShowcase,
|
onViewShowcase,
|
||||||
}: GroupRidesManagementProps) {
|
}: GroupRidesManagementProps) {
|
||||||
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
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 [communityRides, setCommunityRides] = useState<GroupRideSummary[]>([]);
|
||||||
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
|
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
|
||||||
const [communityError, setCommunityError] = useState<string | null>(null);
|
const [communityError, setCommunityError] = useState<string | null>(null);
|
||||||
|
|
@ -1002,66 +1004,55 @@ export function GroupRidesManagement({
|
||||||
|
|
||||||
// Handlers for Add, Alter, Remove, Join, Leave
|
// Handlers for Add, Alter, Remove, Join, Leave
|
||||||
const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string; clubName?: string }) => {
|
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 uid = currentUser?.uid;
|
||||||
const now = new Date();
|
// Check authentication and persistence availability BEFORE touching UI state, so an
|
||||||
const rideId = uid
|
// unauthenticated user (or an unavailable Firestore) never sees a phantom ride that was
|
||||||
? `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}`
|
// never saved. They get an actionable error instead.
|
||||||
: `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.
|
|
||||||
if (!uid || !firestore) {
|
if (!uid || !firestore) {
|
||||||
|
showAlert('Sign in to create a group ride — it could not be saved.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creatingRideRef.current = true;
|
||||||
try {
|
try {
|
||||||
const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer';
|
const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer';
|
||||||
const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider';
|
const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider';
|
||||||
|
|
||||||
const firestoreRideData = buildWebGroupRidePayload(newRide, {
|
const result = await commitGroupRide(newRide, {
|
||||||
rideId,
|
|
||||||
uid,
|
uid,
|
||||||
leadHandle,
|
firestore,
|
||||||
leadDisplayName,
|
leadDisplayName,
|
||||||
now,
|
leadHandle,
|
||||||
|
writeBatch: (fs) => writeBatch(fs),
|
||||||
|
docRef: (fs, ...segments) => doc(fs, segments[0], ...segments.slice(1)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const organizerParticipant = {
|
if (result.ok) {
|
||||||
userId: uid,
|
// Insert into UI state ONLY after Firestore confirms the write — no phantom rides.
|
||||||
role: 'organizer',
|
const rideWithOrganizer: GroupRideSummary = {
|
||||||
status: 'accepted',
|
...newRide,
|
||||||
riderHandle: leadHandle,
|
id: result.rideId,
|
||||||
riderDisplayName: leadDisplayName,
|
isOrganizer: true,
|
||||||
createdAt: now.toISOString(),
|
status: 'scheduled',
|
||||||
updatedAt: now.toISOString(),
|
userStatus: 'joined',
|
||||||
};
|
rideCode: result.rideId,
|
||||||
|
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
|
||||||
const batch = writeBatch(firestore);
|
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
|
||||||
batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData);
|
};
|
||||||
batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant);
|
setRides((prev) => (prev.some((r) => r.id === result.rideId) ? prev : [rideWithOrganizer, ...prev]));
|
||||||
await batch.commit();
|
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${result.rideId}`, 'success');
|
||||||
|
} else if (result.reason === 'commit-failed') {
|
||||||
// Report success only after the write actually commits.
|
console.error('Failed to persist group ride to Firestore:', result.error);
|
||||||
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success');
|
showAlert('Could not create the ride. Please check your connection and try again.', 'error');
|
||||||
} catch (err: any) {
|
} else {
|
||||||
console.error('Failed to persist group ride to Firestore:', err);
|
// unauthenticated / unavailable discovered inside the helper (defensive — guarded above).
|
||||||
// Roll back the optimistic insert and report the failure truthfully.
|
showAlert('Could not create the ride right now. Please try again.', 'error');
|
||||||
setRides((prev) => prev.filter((r) => r.id !== rideId));
|
}
|
||||||
showAlert('Could not create the ride. Please check your connection and try again.', 'error');
|
} finally {
|
||||||
|
creatingRideRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,26 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
||||||
indexRef.current = currentIndex;
|
indexRef.current = currentIndex;
|
||||||
}, [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
|
// Dynamically load Leaflet library and CSS with multi-CDN fallback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
@ -443,7 +463,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
||||||
{ direction: 'top' }
|
{ direction: 'top' }
|
||||||
);
|
);
|
||||||
cornerMarker.on('click', () => {
|
cornerMarker.on('click', () => {
|
||||||
setCurrentIndex(corner.apexIndex);
|
seekTo(corner.apexIndex);
|
||||||
});
|
});
|
||||||
routeGroup.addLayer(cornerMarker);
|
routeGroup.addLayer(cornerMarker);
|
||||||
}
|
}
|
||||||
|
|
@ -666,9 +686,9 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsPlaying((prev) => !prev);
|
setIsPlaying((prev) => !prev);
|
||||||
} else if (e.code === 'ArrowRight' && totalPoints > 0) {
|
} else if (e.code === 'ArrowRight' && totalPoints > 0) {
|
||||||
setCurrentIndex((prev) => Math.min(totalPoints - 1, prev + 20));
|
seekTo(indexRef.current + 20);
|
||||||
} else if (e.code === 'ArrowLeft') {
|
} else if (e.code === 'ArrowLeft') {
|
||||||
setCurrentIndex((prev) => Math.max(0, prev - 20));
|
seekTo(indexRef.current - 20);
|
||||||
} else if (e.code === 'Escape') {
|
} else if (e.code === 'Escape') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
@ -1064,7 +1084,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
||||||
key={corner.id}
|
key={corner.id}
|
||||||
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
|
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentIndex(corner.apexIndex);
|
seekTo(corner.apexIndex);
|
||||||
setActiveTab('map');
|
setActiveTab('map');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1105,7 +1125,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setCurrentIndex(corner.apexIndex);
|
seekTo(corner.apexIndex);
|
||||||
setActiveTab('map');
|
setActiveTab('map');
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 rounded bg-surface-container-high hover:bg-brand-cyan hover:text-black text-[10px] transition cursor-pointer"
|
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}
|
min={0}
|
||||||
max={Math.max(1, totalPoints - 1)}
|
max={Math.max(1, totalPoints - 1)}
|
||||||
value={currentIndex}
|
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"
|
className="flex-1 accent-brand-cyan cursor-pointer h-2 bg-slate-800 rounded-lg"
|
||||||
data-testid="replay-timeline-slider"
|
data-testid="replay-timeline-slider"
|
||||||
disabled={!points.length}
|
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>
|
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
|
||||||
</div>
|
</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 */}
|
{/* Transport Controls */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentIndex(0)}
|
onClick={() => seekTo(0)}
|
||||||
title="Restart from beginning"
|
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"
|
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() }),
|
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 GroupRidesManageRoute from '../app/group-rides/manage/page';
|
||||||
import GroupRidesShowcaseRoute from '../app/group-rides/showcase/page';
|
import GroupRidesShowcaseRoute from '../app/group-rides/showcase/page';
|
||||||
|
|
||||||
describe('Group Rides Interactive Page & Components', () => {
|
describe('Group Rides Interactive Page & Components', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
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();
|
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 onClose = vi.fn();
|
||||||
const onCreateRide = vi.fn();
|
const onCreateRide = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
||||||
|
|
||||||
|
|
@ -200,7 +244,31 @@ describe('Group Rides Interactive Page & Components', () => {
|
||||||
userStatus: 'joined',
|
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)', () => {
|
describe('GroupRidesManagement (For Logged In Users - View, Add, Remove, Alter)', () => {
|
||||||
it('allows viewing, adding, altering, canceling, and deleting group rides', async () => {
|
it('allows viewing, adding, altering, canceling, and deleting group rides', async () => {
|
||||||
const mockUser = { email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
|
render(<GroupRidesManagement currentUser={authedUser} />);
|
||||||
render(<GroupRidesManagement currentUser={mockUser} />);
|
|
||||||
|
|
||||||
// Header & Console title
|
// Header & Console title
|
||||||
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
|
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 }));
|
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||||
|
|
||||||
// Verify newly created ride appears in the list
|
// The ride is inserted only after Firestore confirms the write, so wait for it.
|
||||||
expect(screen.getByRole('heading', { name: /High Sierra Alpine Sprint/i })).toBeDefined();
|
await screen.findByRole('heading', { name: /High Sierra Alpine Sprint/i });
|
||||||
|
|
||||||
// 3. ALTER: Edit existing organized ride parameters
|
// 3. ALTER: Edit existing organized ride parameters
|
||||||
const alterButtons = screen.getAllByRole('button', { name: /alter \/ edit/i });
|
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(/Live Squad Telemetry Stream: Connected/i)).toBeDefined();
|
||||||
expect(screen.getByText(/Formation session #CA-84-SW/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', () => {
|
describe('Full GroupRidesPage Smart Routing', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue