Session 49: Complete onboarding flow + name micro-fixes (2185 tests)

Name micro-fixes (close the normalization arc):
- collapseInitials merges "J C Escarra" -> "JC Escarra" (display + key); both
  playerName.js copies. Added mickey:michael nickname.

Onboarding flow (end-to-end, complete):
- Storage: Supabase user_metadata.preferences (no migration).
- API: src/routes/preferences.js GET/POST (requireAuth, admin getUserById/
  updateUserById, partial merge + sanitize) + Next /api/preferences proxy.
- Page: web onboarding/page.tsx — 3 steps (sports >=1 / books skip / bankroll
  presets+custom+skip) -> SIGNAL ACTIVE -> POST onboarding_complete:true -> 2s
  -> /dashboard. Redirects to login when unauthenticated.
- Redirect: dashboard fetches /api/preferences fresh; new+incomplete users
  (created_at >= cutoff) -> /onboarding; never while auth loading; existing
  users exempt.
- Personalization: Slate default tab = prefs.sports[0]; preferred books glow in
  the card lines grid (lib/books isPreferredBook, threaded dash->Slate->GameCard).
- Settings: PREFERENCES section loads + edits + saves sports/books/limit.

Backend 2156 -> 2185 tests (+29), 184 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-19 10:20:55 -04:00
parent 91b03c4044
commit 3b47b783dc
17 changed files with 687 additions and 17 deletions
+77
View File
@@ -0,0 +1,77 @@
// Session 49 — /api/preferences (auth-gated, user_metadata storage).
const request = require('supertest');
const mockFrom = jest.fn();
const mockGetUser = jest.fn();
const mockGetUserById = jest.fn();
const mockUpdateUserById = jest.fn();
jest.mock('../../src/utils/supabase', () => ({
getSupabaseClient: () => ({ auth: { getUser: mockGetUser, admin: { getUserById: mockGetUserById, updateUserById: mockUpdateUserById } }, from: mockFrom }),
getSupabaseServiceClient: () => ({ auth: { getUser: mockGetUser, admin: { getUserById: mockGetUserById, updateUserById: mockUpdateUserById } }, from: mockFrom }),
}));
const app = require('../../src/app');
const USER_ID = 'user-1';
function authOk() {
mockGetUser.mockResolvedValue({ data: { user: { id: USER_ID, email: 'a@b.com' } }, error: null });
// requireAuth's users-row lookup
mockFrom.mockImplementation(() => ({
select: () => ({ eq: () => ({ single: () => Promise.resolve({ data: { id: USER_ID, email: 'a@b.com', tier: 'free' }, error: null }) }) }),
}));
}
function metaWith(preferences) {
mockGetUserById.mockResolvedValue({ data: { user: { id: USER_ID, user_metadata: preferences ? { preferences } : {} } }, error: null });
mockUpdateUserById.mockResolvedValue({ data: { user: { id: USER_ID } }, error: null });
}
beforeEach(() => { jest.clearAllMocks(); });
describe('GET /api/preferences', () => {
it('requires auth (401 without token)', async () => {
const res = await request(app).get('/api/preferences');
expect(res.status).toBe(401);
});
it('returns defaults for a user with no saved prefs', async () => {
authOk(); metaWith(null);
const res = await request(app).get('/api/preferences').set('Authorization', 'Bearer t');
expect(res.status).toBe(200);
expect(res.body).toEqual({ sports: [], books: [], weekly_limit: null, onboarding_complete: false });
});
it('returns saved preferences', async () => {
authOk(); metaWith({ sports: ['mlb', 'nba'], books: ['draftkings'], weekly_limit: 250, onboarding_complete: true });
const res = await request(app).get('/api/preferences').set('Authorization', 'Bearer t');
expect(res.body.sports).toEqual(['mlb', 'nba']);
expect(res.body.weekly_limit).toBe(250);
expect(res.body.onboarding_complete).toBe(true);
});
});
describe('POST /api/preferences', () => {
it('requires auth', async () => {
const res = await request(app).post('/api/preferences').send({ sports: ['mlb'] });
expect(res.status).toBe(401);
});
it('saves and returns the merged preferences (sanitized)', async () => {
authOk(); metaWith(null);
const res = await request(app).post('/api/preferences').set('Authorization', 'Bearer t')
.send({ sports: ['MLB', 'nba', 'cricket'], books: ['draftkings'], weekly_limit: 250, onboarding_complete: true });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.preferences.sports).toEqual(['mlb', 'nba']); // lowercased, invalid dropped
expect(res.body.preferences.onboarding_complete).toBe(true);
expect(mockUpdateUserById).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ user_metadata: expect.objectContaining({ preferences: expect.any(Object) }) }));
});
it('partial update merges with existing (does not overwrite other keys)', async () => {
authOk(); metaWith({ sports: ['mlb'], books: ['fanduel'], weekly_limit: 100, onboarding_complete: true });
const res = await request(app).post('/api/preferences').set('Authorization', 'Bearer t').send({ weekly_limit: 500 });
expect(res.body.preferences.weekly_limit).toBe(500);
expect(res.body.preferences.sports).toEqual(['mlb']); // preserved
expect(res.body.preferences.books).toEqual(['fanduel']); // preserved
});
});
+30
View File
@@ -0,0 +1,30 @@
// Session 49 — Phase 1: name micro-fixes (initials collapse + mickey nickname).
const be = require('../../src/utils/playerName');
const fe = require('../../web/src/lib/playerName');
describe('initials collapse', () => {
it('"J C Escarra" === "JC Escarra"', () => {
expect(be.nameKey('J C Escarra')).toBe(be.nameKey('JC Escarra'));
expect(be.normalizeName('J C Escarra').display).toBe('JC Escarra');
});
it('handles 3+ initials ("J C E Smith" → "JCE Smith")', () => {
expect(be.normalizeName('J C E Smith').display).toBe('JCE Smith');
});
it('does not collapse a single initial before a real word ("O Henry")', () => {
expect(be.normalizeName('O Henry').display).toBe('O Henry');
});
});
describe('mickey nickname', () => {
it('"Mickey Gasper" === "Michael Gasper"', () => {
expect(be.nameKey('Mickey Gasper')).toBe(be.nameKey('Michael Gasper'));
});
});
describe('frontend + backend copies agree', () => {
it.each(['J C Escarra', 'Mickey Gasper', 'J C E Smith', 'O Henry', 'Aaron Judge'])('%s', (n) => {
expect(fe.nameKey(n)).toBe(be.nameKey(n));
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
});
});
+94
View File
@@ -0,0 +1,94 @@
// Session 49 — onboarding flow: page, redirect, personalization, settings, book
// highlight. Page logic is asserted as source (the journey is structural);
// book-matching logic runs directly.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const books = require('../../web/src/lib/books');
describe('isPreferredBook (book matching)', () => {
it('matches a book across id / code / name forms', () => {
expect(books.isPreferredBook('DK', ['draftkings'])).toBe(true);
expect(books.isPreferredBook('DraftKings', ['draftkings'])).toBe(true);
expect(books.isPreferredBook('draftkings', ['DK'])).toBe(true);
expect(books.isPreferredBook('FD', ['draftkings'])).toBe(false);
});
it('returns false for empty/missing preferences', () => {
expect(books.isPreferredBook('DK', [])).toBe(false);
expect(books.isPreferredBook('DK', undefined)).toBe(false);
});
});
describe('Onboarding page', () => {
const src = read('app/onboarding/page.tsx');
it('renders 3 steps + a completion step', () => {
expect(src).toContain('data-step="1"');
expect(src).toContain('data-step="2"');
expect(src).toContain('data-step="3"');
expect(src).toContain('SIGNAL ACTIVE');
expect(src).toContain("You&apos;re locked in");
});
it('step 1 requires at least one sport', () => {
expect(src).toContain('disabled={sports.length === 0}');
});
it('saves preferences via POST with onboarding_complete:true → /dashboard', () => {
expect(src).toContain("fetch('/api/preferences'");
expect(src).toContain('onboarding_complete: true');
expect(src).toContain("router.replace('/dashboard')");
});
it('redirects to login when not signed in (never to onboarding)', () => {
expect(src).toContain("router.replace('/login?next=/onboarding')");
});
});
describe('Dashboard onboarding redirect + personalization', () => {
const src = read('app/dashboard/page.tsx');
it('fetches prefs and redirects new incomplete users to /onboarding', () => {
expect(src).toContain("fetch('/api/preferences'");
expect(src).toContain("router.replace('/onboarding')");
expect(src).toContain('onboarding_complete !== true');
});
it('exempts existing users via created_at cutoff', () => {
expect(src).toContain('ONBOARDING_CUTOFF');
expect(src).toContain('isNewUser');
});
it('does not redirect while auth is loading', () => {
expect(src).toContain('if (authLoading || !user || !session?.access_token) return;');
});
it('passes primary sport tab + preferred books to the Slate', () => {
expect(src).toContain('initialTab={primaryTab}');
expect(src).toContain('preferredBooks={prefBooks}');
});
});
describe('Slate + GameCard thread preferred books', () => {
it('Slate accepts + forwards preferredBooks', () => {
const src = read('components/Slate.tsx');
expect(src).toContain('preferredBooks');
expect(src).toContain('preferredBooks={preferredBooks}');
});
it('GameCard highlights the preferred book row', () => {
const src = read('components/vyndr/GameCard.tsx');
expect(src).toContain('isPreferredBook(ln.book, preferredBooks)');
});
});
describe('Settings preferences section', () => {
const src = read('app/settings/page.tsx');
it('renders a PREFERENCES section that loads + saves prefs', () => {
expect(src).toContain('label="PREFERENCES"');
expect(src).toContain("fetch('/api/preferences'");
expect(src).toContain('savePrefs');
});
});
describe('Preferences Next proxy', () => {
it('forwards GET + POST with auth', () => {
const src = read('app/api/preferences/route.ts');
expect(src).toContain('export async function GET');
expect(src).toContain('export async function POST');
expect(src).toContain('Authorization');
});
});