3b47b783dc
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>
78 lines
3.7 KiB
JavaScript
78 lines
3.7 KiB
JavaScript
// 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
|
|
});
|
|
});
|