482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
import React from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import AccountPage from '../app/account/page';
|
|
import { AppCloudData } from '../components/app-cloud-data';
|
|
import { firebaseAuth } from '../lib/firebase-client';
|
|
|
|
const authListeners = new Set<(user: unknown) => void>();
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getDocs: vi.fn(),
|
|
deleteDoc: vi.fn(),
|
|
updateDoc: vi.fn(),
|
|
addDoc: vi.fn(),
|
|
writeBatch: vi.fn(() => ({
|
|
update: vi.fn(),
|
|
commit: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
usePathname: () => '/account',
|
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock('../lib/firebase-client', () => ({
|
|
firebaseAuth: {},
|
|
firestore: {},
|
|
}));
|
|
|
|
vi.mock('firebase/auth', () => ({
|
|
onAuthStateChanged: (_auth: unknown, deliver: (user: unknown) => void) => {
|
|
authListeners.add(deliver);
|
|
return () => {
|
|
authListeners.delete(deliver);
|
|
};
|
|
},
|
|
multiFactor: () => ({ enrolledFactors: [] }),
|
|
}));
|
|
|
|
vi.mock('firebase/firestore', () => ({
|
|
collection: (...args: unknown[]) => ({ path: args.slice(1).join('/') }),
|
|
doc: (...args: unknown[]) => ({ path: args.slice(1).join('/'), id: args[args.length - 1] }),
|
|
getDocs: (...args: unknown[]) => mocks.getDocs(...args),
|
|
deleteDoc: (...args: unknown[]) => mocks.deleteDoc(...args),
|
|
updateDoc: (...args: unknown[]) => mocks.updateDoc(...args),
|
|
addDoc: (...args: unknown[]) => mocks.addDoc(...args),
|
|
writeBatch: () => mocks.writeBatch(),
|
|
}));
|
|
|
|
function deliverAuth(user: unknown) {
|
|
(firebaseAuth as { currentUser?: unknown }).currentUser = user;
|
|
authListeners.forEach((listener) => listener(user));
|
|
}
|
|
|
|
describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
authListeners.clear();
|
|
(firebaseAuth as { currentUser?: unknown }).currentUser = undefined;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const authUser = {
|
|
uid: 'rider-123',
|
|
email: 'trackday@example.com',
|
|
displayName: 'Test Rider',
|
|
};
|
|
|
|
it('distinguishes initial loading: shows Loading… and Checking sync…, never 0 miles, 0 rides, or 100% Cloud Synced', async () => {
|
|
// Keep getDocs pending so it stays in loading state
|
|
let resolveMotorcycles: (val: unknown) => void;
|
|
let resolveRides: (val: unknown) => void;
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (colRef.path.includes('motorcycles')) {
|
|
return new Promise((res) => { resolveMotorcycles = res; });
|
|
}
|
|
return new Promise((res) => { resolveRides = res; });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
// Metric 1: Distance must show Loading…, not 0 miles
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Loading…');
|
|
expect(distanceCard.textContent).not.toContain('0 miles');
|
|
|
|
// Metric 2: Sessions must show Checking sync… or Loading…, not 0 rides or 100% Cloud Synced
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Checking sync…');
|
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
|
|
// Metric 3: Lean Angle Peak must show Loading…, not 0 or false verified roll
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('Loading…');
|
|
expect(leanCard.textContent).toContain('Retrieving lean telemetry…');
|
|
});
|
|
|
|
it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => {
|
|
render(<AccountPage />);
|
|
deliverAuth(null);
|
|
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Sign in to view');
|
|
expect(distanceCard.textContent).toContain('—');
|
|
expect(distanceCard.textContent).not.toContain('0 miles');
|
|
});
|
|
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Sign-in required');
|
|
expect(sessionsCard.textContent).toContain('—');
|
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('Sign in to view');
|
|
expect(leanCard.textContent).toContain('—');
|
|
});
|
|
|
|
it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => {
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
return Promise.resolve({ docs: [], empty: true });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('0');
|
|
expect(distanceCard.textContent).toContain('miles');
|
|
expect(distanceCard.textContent).toContain('Cloud Aggregate');
|
|
});
|
|
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('0');
|
|
expect(sessionsCard.textContent).toContain('rides');
|
|
// Must NOT claim "100% Cloud Synced" on an empty account with no rides
|
|
expect(sessionsCard.textContent).toContain('Cloud Synced');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
expect(sessionsCard.textContent).toContain('No rides recorded yet');
|
|
});
|
|
|
|
it('displays real calculated totals for populated accounts and claims 100% Cloud Synced only when rides exist', async () => {
|
|
const mockRides = [
|
|
{
|
|
id: 'ride-1',
|
|
data: () => ({
|
|
startTime: '2026-07-01T10:00:00Z',
|
|
endTime: '2026-07-01T11:00:00Z',
|
|
distanceMiles: 45.2,
|
|
topSpeedMph: 72.4,
|
|
averageSpeed: 45.2,
|
|
maxLeanLeft: 42.1,
|
|
maxLeanRight: 45.6,
|
|
maxAccelG: 0.65,
|
|
maxBrakingG: 0.82,
|
|
movingTime: 3600,
|
|
elevationGain: 850,
|
|
qualityScore: 98,
|
|
motorcycleYear: 2024,
|
|
motorcycleMake: 'Yamaha',
|
|
motorcycleModel: 'YZF-R7',
|
|
}),
|
|
},
|
|
{
|
|
id: 'ride-2',
|
|
data: () => ({
|
|
startTime: '2026-07-02T14:00:00Z',
|
|
endTime: '2026-07-02T15:30:00Z',
|
|
distanceMiles: 54.8,
|
|
topSpeedMph: 85.0,
|
|
averageSpeed: 52.0,
|
|
maxLeanLeft: 48.5,
|
|
maxLeanRight: 46.0,
|
|
maxAccelG: 0.75,
|
|
maxBrakingG: 0.90,
|
|
movingTime: 5400,
|
|
elevationGain: 1200,
|
|
qualityScore: 95,
|
|
motorcycleYear: 2024,
|
|
motorcycleMake: 'Yamaha',
|
|
motorcycleModel: 'YZF-R7',
|
|
}),
|
|
},
|
|
];
|
|
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (colRef.path.includes('rides')) {
|
|
return Promise.resolve({ docs: mockRides, empty: false });
|
|
}
|
|
return Promise.resolve({ docs: [], empty: true });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
// 45.2 + 54.8 = 100 miles
|
|
expect(distanceCard.textContent).toContain('100');
|
|
expect(distanceCard.textContent).toContain('miles');
|
|
});
|
|
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('2');
|
|
expect(sessionsCard.textContent).toContain('rides');
|
|
// With real rides loaded, it is confirmed 100% Cloud Synced
|
|
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
|
expect(sessionsCard.textContent).toContain('Canyons, Circuits & Commutes');
|
|
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('48.5°');
|
|
expect(leanCard.textContent).toContain('6-Axis IMU Verified');
|
|
});
|
|
|
|
it('truthfully handles Firestore permission denied: shows Unavailable and Permission denied, never verified zero totals', async () => {
|
|
mocks.getDocs.mockImplementation(() => {
|
|
const err = new Error('Missing or insufficient permissions.');
|
|
(err as unknown as { code: string }).code = 'permission-denied';
|
|
return Promise.reject(err);
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Unavailable');
|
|
expect(distanceCard.textContent).toContain('Permission denied');
|
|
expect(distanceCard.textContent).not.toContain('0 miles');
|
|
});
|
|
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Unavailable');
|
|
expect(sessionsCard.textContent).toContain('Permission denied');
|
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('Unavailable');
|
|
expect(leanCard.textContent).toContain('Permission denied');
|
|
expect(leanCard.textContent).toContain('Lean telemetry restricted');
|
|
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
|
|
|
// AppCloudData error banners
|
|
expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
|
|
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
|
expect(screen.getByText(/Recent Rides Restricted — Permission Denied/i)).toBeDefined();
|
|
});
|
|
|
|
it('truthfully handles network / temporary backend failure: shows Unavailable and Sync status unavailable', async () => {
|
|
mocks.getDocs.mockImplementation(() => {
|
|
const err = new Error('Failed to fetch: client is offline');
|
|
(err as unknown as { code: string }).code = 'unavailable';
|
|
return Promise.reject(err);
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Unavailable');
|
|
expect(distanceCard.textContent).toContain('Sync unavailable');
|
|
expect(distanceCard.textContent).not.toContain('0 miles');
|
|
});
|
|
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Unavailable');
|
|
expect(sessionsCard.textContent).toContain('Sync status unavailable');
|
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('Unavailable');
|
|
expect(leanCard.textContent).toContain('Sync unavailable');
|
|
expect(leanCard.textContent).toContain('Lean telemetry unavailable');
|
|
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
|
|
|
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
|
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined();
|
|
});
|
|
|
|
it('handles partial data availability: shows garage when motorcycles load, but keeps rides metrics Unavailable when rides query fails', async () => {
|
|
const mockBike = {
|
|
id: 'bike-1',
|
|
data: () => ({
|
|
year: 2024,
|
|
makeName: 'Ducati',
|
|
modelName: 'Panigale V4',
|
|
variantName: 'SP2',
|
|
nickname: 'Red Bullet',
|
|
isDefault: true,
|
|
}),
|
|
};
|
|
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (colRef.path.includes('motorcycles')) {
|
|
return Promise.resolve({ docs: [mockBike], empty: false });
|
|
}
|
|
const err = new Error('Rides query unavailable');
|
|
(err as unknown as { code: string }).code = 'unavailable';
|
|
return Promise.reject(err);
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
await waitFor(() => {
|
|
// Bike rendered in garage
|
|
expect(screen.getByText('Red Bullet')).toBeDefined();
|
|
});
|
|
|
|
// But ride metrics MUST show Unavailable, not zero!
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Unavailable');
|
|
expect(sessionsCard.textContent).not.toContain('0 rides');
|
|
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
|
|
|
// Rides feed in AppCloudData shows error
|
|
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
|
});
|
|
|
|
it('recovers after retry: Quick Sync displays error on failure and updates to Synced on successful retry', async () => {
|
|
let shouldFail = true;
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (shouldFail) {
|
|
const err = new Error('Network error');
|
|
(err as unknown as { code: string }).code = 'unavailable';
|
|
return Promise.reject(err);
|
|
}
|
|
if (colRef.path.includes('rides')) {
|
|
return Promise.resolve({
|
|
docs: [
|
|
{
|
|
id: 'ride-1',
|
|
data: () => ({
|
|
startTime: '2026-07-01T10:00:00Z',
|
|
endTime: '2026-07-01T11:00:00Z',
|
|
distanceMiles: 15.0,
|
|
topSpeedMph: 60,
|
|
averageSpeed: 35,
|
|
maxLeanLeft: 30,
|
|
maxLeanRight: 32,
|
|
maxAccelG: 0.5,
|
|
maxBrakingG: 0.6,
|
|
movingTime: 1200,
|
|
elevationGain: 200,
|
|
qualityScore: 90,
|
|
motorcycleYear: 2024,
|
|
motorcycleMake: 'Yamaha',
|
|
motorcycleModel: 'MT-07',
|
|
}),
|
|
},
|
|
],
|
|
empty: false,
|
|
});
|
|
}
|
|
return Promise.resolve({ docs: [], empty: true });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
// Initial failure
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Sync Failed \(Retry\)/i)).toBeDefined();
|
|
});
|
|
|
|
// Make next fetch succeed
|
|
shouldFail = false;
|
|
|
|
// Click retry via Quick Sync button
|
|
const quickSyncBtn = screen.getByRole('button', { name: /Sync Failed \(Retry\)/i });
|
|
fireEvent.click(quickSyncBtn);
|
|
|
|
// Recovered state
|
|
await waitFor(() => {
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('1');
|
|
expect(sessionsCard.textContent).toContain('rides');
|
|
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
|
});
|
|
|
|
expect(screen.getByText(/Synced \(/i)).toBeDefined();
|
|
});
|
|
|
|
it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => {
|
|
let resolveMotorcycles: (val: unknown) => void;
|
|
let resolveRides: (val: unknown) => void;
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (colRef.path.includes('motorcycles')) {
|
|
return new Promise((res) => { resolveMotorcycles = res; });
|
|
}
|
|
return new Promise((res) => { resolveRides = res; });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
// Initial loading
|
|
expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…');
|
|
|
|
// User logs out while fetch is still in-flight
|
|
deliverAuth(null);
|
|
|
|
// Now the in-flight promise resolves with User A's data
|
|
resolveMotorcycles!({
|
|
docs: [{
|
|
id: 'bike-user-a',
|
|
data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }),
|
|
}],
|
|
empty: false,
|
|
});
|
|
resolveRides!({
|
|
docs: [{
|
|
id: 'ride-user-a',
|
|
data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }),
|
|
}],
|
|
empty: false,
|
|
});
|
|
|
|
// Wait to confirm User A's ghost data does NOT leak into logged-out state
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Sign in to view');
|
|
expect(distanceCard.textContent).not.toContain('50');
|
|
expect(screen.queryByText('Ghost Bike')).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => {
|
|
let shouldFail = false;
|
|
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
|
if (shouldFail) {
|
|
const err = new Error('Permission denied on re-sync');
|
|
(err as unknown as { code: string }).code = 'permission-denied';
|
|
return Promise.reject(err);
|
|
}
|
|
if (colRef.path.includes('rides')) {
|
|
return Promise.resolve({
|
|
docs: [{
|
|
id: 'ride-1',
|
|
data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }),
|
|
}],
|
|
empty: false,
|
|
});
|
|
}
|
|
return Promise.resolve({ docs: [], empty: true });
|
|
});
|
|
|
|
render(<AccountPage />);
|
|
deliverAuth(authUser);
|
|
|
|
// Verify initial successful sync
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('metric-card-distance').textContent).toContain('25');
|
|
});
|
|
|
|
// Now trigger Quick Sync and make it fail
|
|
shouldFail = true;
|
|
const quickSyncBtn = screen.getByRole('button', { name: /Synced/i });
|
|
fireEvent.click(quickSyncBtn);
|
|
|
|
// Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles
|
|
await waitFor(() => {
|
|
const distanceCard = screen.getByTestId('metric-card-distance');
|
|
expect(distanceCard.textContent).toContain('Unavailable');
|
|
expect(distanceCard.textContent).not.toContain('25');
|
|
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
|
expect(sessionsCard.textContent).toContain('Unavailable');
|
|
expect(sessionsCard.textContent).not.toContain('1 rides');
|
|
const leanCard = screen.getByTestId('metric-card-lean');
|
|
expect(leanCard.textContent).toContain('Unavailable');
|
|
});
|
|
});
|
|
});
|