139 lines
5.2 KiB
TypeScript
139 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { commitGroupRide, resetRideIdCounter } 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)', () => {
|
||
beforeEach(() => {
|
||
resetRideIdCounter();
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
it('guarantees unique ride IDs across concurrent submissions at the same millisecond', async () => {
|
||
const sharedNow = new Date('2026-10-14T12:00:00.000Z');
|
||
const { deps: deps1 } = makeDeps({ now: sharedNow });
|
||
const { deps: deps2 } = makeDeps({ now: sharedNow });
|
||
|
||
const [res1, res2] = await Promise.all([
|
||
commitGroupRide(baseRide, deps1),
|
||
commitGroupRide(baseRide, deps2),
|
||
]);
|
||
|
||
expect(res1.ok).toBe(true);
|
||
expect(res2.ok).toBe(true);
|
||
if (res1.ok && res2.ok) {
|
||
expect(res1.rideId).not.toBe(res2.rideId);
|
||
expect(res1.rideId).toMatch(/^ride_\d+_rider-/);
|
||
expect(res2.rideId).toMatch(/^ride_\d+_rider-/);
|
||
}
|
||
});
|
||
|
||
it('preserves clean timestamps without string suffix corruption on successive rides', async () => {
|
||
const t1 = new Date('2026-10-14T12:00:00.000Z');
|
||
const t2 = new Date('2026-10-14T12:05:00.000Z');
|
||
const { deps: deps1 } = makeDeps({ now: t1 });
|
||
const { deps: deps2 } = makeDeps({ now: t2 });
|
||
|
||
const res1 = await commitGroupRide(baseRide, deps1);
|
||
const res2 = await commitGroupRide(baseRide, deps2);
|
||
|
||
expect(res1.ok).toBe(true);
|
||
expect(res2.ok).toBe(true);
|
||
if (res1.ok && res2.ok) {
|
||
// Must equal exact millisecond timestamp, NOT corrupted with appended counter digits
|
||
expect(res1.rideId).toBe(`ride_${t1.getTime()}_rider-`);
|
||
expect(res2.rideId).toBe(`ride_${t2.getTime()}_rider-`);
|
||
}
|
||
});
|
||
|
||
it('handles permission-denied or rejected commit cleanly without throwing', async () => {
|
||
const permDenied = new Error('permission-denied: caller lacks organizer permission');
|
||
const { deps } = makeDeps({
|
||
writeBatch: () => ({ set: vi.fn(), commit: vi.fn().mockRejectedValue(permDenied) }),
|
||
});
|
||
const res = await commitGroupRide(baseRide, deps);
|
||
expect(res.ok).toBe(false);
|
||
if (!res.ok) {
|
||
expect(res.reason).toBe('commit-failed');
|
||
expect((res as any).error).toBe(permDenied);
|
||
}
|
||
});
|
||
});
|