MotorSignal-App/website/tests/account-metrics-truthfulnes...

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');
});
});
});