diff --git a/docs/reports/coverage-2026-08-23.md b/docs/reports/coverage-2026-08-23.md new file mode 100644 index 0000000..4ff67f2 --- /dev/null +++ b/docs/reports/coverage-2026-08-23.md @@ -0,0 +1,55 @@ +# Coverage Report — 2026-08-23 + +## Summary + +| Metric | Before | After | +|--------|--------|-------| +| Statements | 89.56% | 93.47% | +| Branches | 77.41% | 81.60% | +| Functions | 79.41% | 97.05% | +| Lines | 92.49% | 95.08% | + +Total tests: 265 → 277 (+12 new tests). + +## Week-over-Week (vs 2026-06-21) + +| Metric | 2026-06-21 | 2026-08-23 | Change | +|--------|-----------|-----------|--------| +| Statements | 89.56% | 93.47% | +3.91% | +| Branches | 77.41% | 81.60% | +4.19% | +| Functions | 79.41% | 97.05% | +17.64% | +| Lines | 92.49% | 95.08% | +2.59% | +| Test count | 265 | 277 | +12 | + +Files with improved coverage: +- `lib/activity.js` — 77.58% → 97.41% statements, 29.16% → 100% functions +- `lib/notifications.js` — 87.27% → 100% statements (all metrics now 100%) +- `lib/reactions.js` — 90.47% → 95.23% statements, 81.25% → 89.58% branches + +## Files Changed (last 7 days) + +No `lib/` files were modified in the last 7 days. Fell back to lowest-coverage files overall. + +## New Tests Written + +- `tests/unit/lib/activity.test.js` — 5 new tests, covers: getGroupActivity (happy path with profiles + reactions, missing profile fallback), getAllActivity (test-data filtering, profile + reaction attachment), getGroupStats (full stats + leaderboard computation) +- `tests/unit/lib/notifications.test.js` — 4 new tests, covers: getNotifications (happy path, null data, DB error, custom limit) +- `tests/unit/lib/reactions.test.js` — 3 new tests, covers: toggleReaction (non-PGRST116 error), getBatchReactions (unknown activity_id in data, auth failure graceful handling) + +## Skipped (out of scope) + +- `lib/FocusContext.js`, `lib/NotificationContext.js`, `lib/KeyboardShortcutsContext.js` — React context providers +- `lib/animations.js` — pure animation data presets, no logic to test +- `lib/supabase/*` — Supabase client configuration +- `lib/confetti.js` — relies on DOM APIs; browser-only with no testable pure logic +- `lib/sounds.js` — relies on Web Audio API; browser-only with no testable pure logic +- `lib/email.js` — core logic in non-exported functions; only export requires real Resend client +- `lib/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks, out of scope +- `lib/streaks-advanced.js` — remaining uncovered lines (247, 355-356, 377) are in dynamic import / notification paths that require integration-level mocking; coverage at 84.12% statements + +## Notes + +- The biggest win was `activity.js` functions coverage jumping from 29.16% to 100%. The `getGroupActivity`, `getAllActivity`, and `getGroupStats` functions required a multi-table mock (separate builders per `from(table)` call) to test their happy paths — a `createActivityTableMock` helper was added to the test file for this purpose. +- `notifications.js` reached 100% across all metrics by adding the previously-missing `getNotifications` tests. +- `reactions.js` branch coverage improved by testing the `getBatchReactions` edge case where returned data includes an `activity_id` not in the original query array (line 70), and the `toggleReaction` non-PGRST116 error path (line 103). +- `streaks-advanced.js` remains at 84.12% statements. The uncovered lines are in dynamic `import()` blocks for notifications inside `awardStreakFreeze` and `checkStreakMilestone` — testing those would require mocking ES module dynamic imports, which adds complexity disproportionate to the coverage gain. diff --git a/tests/unit/lib/activity.test.js b/tests/unit/lib/activity.test.js index b55c38b..40087d4 100644 --- a/tests/unit/lib/activity.test.js +++ b/tests/unit/lib/activity.test.js @@ -319,4 +319,204 @@ describe('getGroupStats', () => { expect(result.leaderboard).toEqual([]); expect(result.error).toBeNull(); }); + + it('computes stats and leaderboard from real data', async () => { + const { supabase, builders } = createActivityTableMock(['group_members', 'activity_log', 'tasks', 'profiles']); + + builders['group_members'].resolveWith({ + data: [{ user_id: 'u1' }, { user_id: 'u2' }], + error: null, + }); + + builders['activity_log'].resolveWith({ + data: [ + { user_id: 'u1' }, + { user_id: 'u1' }, + { user_id: 'u2' }, + ], + error: null, + }); + + builders['tasks'].resolveWith({ + data: [ + { id: 't1', status: 'done', owner_id: 'u1' }, + { id: 't2', status: 'done', owner_id: 'u2' }, + { id: 't3', status: 'in_progress', owner_id: 'u1' }, + { id: 't4', status: 'todo', owner_id: 'u2' }, + ], + error: null, + }); + + builders['profiles'].resolveWith({ + data: [ + { id: 'u1', full_name: 'Alice', avatar_url: null }, + { id: 'u2', full_name: 'Bob', avatar_url: null }, + ], + error: null, + }); + + const result = await getGroupStats(supabase, 'group-1'); + + expect(result.error).toBeNull(); + expect(result.stats).toEqual({ + totalTasks: 4, + completedTasks: 2, + completionRate: 50, + activeTasks: 2, + }); + expect(result.leaderboard).toHaveLength(2); + expect(result.leaderboard[0].full_name).toBe('Alice'); + expect(result.leaderboard[0].completions).toBe(2); + expect(result.leaderboard[1].full_name).toBe('Bob'); + expect(result.leaderboard[1].completions).toBe(1); + }); +}); + +/** + * Multi-table mock: each from(table) call returns a distinct builder so + * sequential queries against different tables resolve independently. + */ +function createActivityTableMock(tables) { + function makeBuilder() { + const chainMethods = [ + 'select', 'eq', 'neq', 'in', 'not', 'gte', 'order', 'range', + 'single', 'maybeSingle', 'insert', 'update', 'delete', 'limit', + ]; + const b = { + resolveWith(value) { + b.then = (resolve) => resolve(value); + }, + }; + chainMethods.forEach((m) => { + b[m] = vi.fn(() => b); + }); + b.resolveWith({ data: null, error: null }); + return b; + } + + const builders = {}; + (tables || []).forEach((t) => { builders[t] = makeBuilder(); }); + + const supabase = { + from: vi.fn((table) => { + if (!builders[table]) builders[table] = makeBuilder(); + return builders[table]; + }), + rpc: vi.fn().mockResolvedValue({ data: null, error: null }), + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: 'test-user-id' } }, + error: null, + }), + }, + }; + return { supabase, builders }; +} + +describe('getGroupActivity — happy path', () => { + it('attaches user profiles and reactions to activities', async () => { + const { supabase, builders } = createActivityTableMock(['activity_log', 'profiles', 'activity_reactions']); + + builders['activity_log'].resolveWith({ + data: [ + { id: 'act-1', user_id: 'u1', action: 'task_completed', group_id: 'g1', metadata: {} }, + { id: 'act-2', user_id: 'u2', action: 'pact_created', group_id: 'g1', metadata: {} }, + ], + error: null, + }); + + builders['profiles'].resolveWith({ + data: [ + { id: 'u1', full_name: 'Alice', avatar_url: 'https://example.com/a.png' }, + { id: 'u2', full_name: 'Bob', avatar_url: null }, + ], + error: null, + }); + + builders['activity_reactions'].resolveWith({ + data: [ + { activity_id: 'act-1', user_id: 'test-user-id', reaction: 'fire' }, + ], + error: null, + }); + + const result = await getGroupActivity(supabase, 'g1'); + + expect(result.error).toBeNull(); + expect(result.data).toHaveLength(2); + expect(result.data[0].user.full_name).toBe('Alice'); + expect(result.data[0].reactions.counts).toEqual({ fire: 1 }); + expect(result.data[0].reactions.userReactions).toEqual(['fire']); + expect(result.data[1].user.full_name).toBe('Bob'); + expect(result.data[1].reactions.total).toBe(0); + }); + + it('falls back to Unknown for missing profiles', async () => { + const { supabase, builders } = createActivityTableMock(['activity_log', 'profiles', 'activity_reactions']); + + builders['activity_log'].resolveWith({ + data: [{ id: 'act-1', user_id: 'deleted-user', action: 'task_completed', group_id: 'g1', metadata: {} }], + error: null, + }); + + builders['profiles'].resolveWith({ data: [], error: null }); + builders['activity_reactions'].resolveWith({ data: [], error: null }); + + const result = await getGroupActivity(supabase, 'g1'); + + expect(result.data[0].user.full_name).toBe('Unknown'); + expect(result.data[0].user.avatar_url).toBeNull(); + }); +}); + +describe('getAllActivity — happy path', () => { + it('filters out test-data entries and respects the limit', async () => { + const { supabase, builders } = createActivityTableMock(['activity_log', 'profiles', 'activity_reactions']); + + builders['activity_log'].resolveWith({ + data: [ + { id: 'a1', user_id: 'u1', action: 'pact_completed', metadata: { title: 'Study math' } }, + { id: 'a2', user_id: 'u1', action: 'pact_created', metadata: { title: 'Bulk Test Pact' } }, + { id: 'a3', user_id: 'u1', action: 'task_completed', metadata: { title: '[TEST] ignore me' } }, + { id: 'a4', user_id: 'u1', action: 'pact_completed', metadata: { title: 'Read chapter 5' } }, + ], + error: null, + }); + + builders['profiles'].resolveWith({ + data: [{ id: 'u1', full_name: 'Alice', avatar_url: null }], + error: null, + }); + + builders['activity_reactions'].resolveWith({ data: [], error: null }); + + const result = await getAllActivity(supabase, 2); + + expect(result.error).toBeNull(); + const titles = result.data.map((a) => a.metadata.title); + expect(titles).not.toContain('Bulk Test Pact'); + expect(titles).not.toContain('[TEST] ignore me'); + expect(result.data.length).toBeLessThanOrEqual(2); + }); + + it('attaches user profiles and default reactions', async () => { + const { supabase, builders } = createActivityTableMock(['activity_log', 'profiles', 'activity_reactions']); + + builders['activity_log'].resolveWith({ + data: [{ id: 'a1', user_id: 'u1', action: 'pact_completed', metadata: { title: 'Run 5k' } }], + error: null, + }); + + builders['profiles'].resolveWith({ + data: [{ id: 'u1', full_name: 'Alice', avatar_url: null }], + error: null, + }); + + builders['activity_reactions'].resolveWith({ data: [], error: null }); + + const result = await getAllActivity(supabase); + + expect(result.data[0].user.full_name).toBe('Alice'); + expect(result.data[0].reactions).toEqual({ counts: {}, userReactions: [], total: 0 }); + }); }); diff --git a/tests/unit/lib/notifications.test.js b/tests/unit/lib/notifications.test.js index 41bbdff..8c8d0d0 100644 --- a/tests/unit/lib/notifications.test.js +++ b/tests/unit/lib/notifications.test.js @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { NOTIFICATION_TYPES, getNotificationIcon, + getNotifications, getUnreadCount, markAsRead, markAllAsRead, @@ -9,6 +10,48 @@ import { } from '@/lib/notifications'; import { createMockSupabase } from '../../setup/supabase-mock'; +describe('getNotifications', () => { + it('returns notifications on success', async () => { + const { supabase, builder } = createMockSupabase(); + const rows = [ + { id: 'n1', type: 'pact_reminder', title: 'Reminder', message: 'Due soon', is_read: false, created_at: '2024-06-15T12:00:00Z' }, + { id: 'n2', type: 'streak_milestone', title: '7 days!', message: 'Keep going', is_read: true, created_at: '2024-06-14T12:00:00Z' }, + ]; + builder.mockReturnValue({ data: rows, error: null }); + + const result = await getNotifications(supabase); + expect(result.data).toEqual(rows); + expect(result.error).toBeNull(); + }); + + it('returns empty array when data is null', async () => { + const { supabase, builder } = createMockSupabase(); + builder.mockReturnValue({ data: null, error: null }); + + const result = await getNotifications(supabase); + expect(result.data).toEqual([]); + expect(result.error).toBeNull(); + }); + + it('returns empty array and error on DB failure', async () => { + const { supabase, builder } = createMockSupabase(); + builder.mockReturnValue({ data: null, error: { message: 'DB down' } }); + + const result = await getNotifications(supabase); + expect(result.data).toEqual([]); + expect(result.error).toBeTruthy(); + }); + + it('respects a custom limit', async () => { + const { supabase, builder } = createMockSupabase(); + builder.mockReturnValue({ data: [{ id: 'n1' }], error: null }); + + const result = await getNotifications(supabase, 5); + expect(result.data).toHaveLength(1); + expect(builder.limit).toHaveBeenCalledWith(5); + }); +}); + describe('NOTIFICATION_TYPES', () => { it('is an object with string values', () => { expect(typeof NOTIFICATION_TYPES).toBe('object'); diff --git a/tests/unit/lib/reactions.test.js b/tests/unit/lib/reactions.test.js index 974c110..9d114e1 100644 --- a/tests/unit/lib/reactions.test.js +++ b/tests/unit/lib/reactions.test.js @@ -194,4 +194,46 @@ describe('toggleReaction', () => { expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); + + it('throws on non-PGRST116 error from existence check', async () => { + const { supabase, builder } = createMockSupabase(); + builder.mockReturnValue({ data: null, error: { code: 'INTERNAL', message: 'unexpected' } }); + + const result = await toggleReaction(supabase, 'a1', 'fire'); + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); + +describe('getBatchReactions — edge cases', () => { + it('handles reactions for activity IDs not in the original array', async () => { + const { supabase, builder } = createMockSupabase(); + builder.mockReturnValue({ + data: [ + { activity_id: 'a1', user_id: 'test-user-id', reaction: 'fire' }, + { activity_id: 'unknown-id', user_id: 'test-user-id', reaction: 'clap' }, + ], + error: null, + }); + + const result = await getBatchReactions(supabase, ['a1']); + expect(result.reactionsMap.a1.counts).toEqual({ fire: 1 }); + expect(result.reactionsMap['unknown-id'].counts).toEqual({ clap: 1 }); + expect(result.reactionsMap['unknown-id'].total).toBe(1); + }); + + it('handles auth failure gracefully (no userReactions tracked)', async () => { + const { supabase, builder } = createMockSupabase(); + supabase.auth.getUser.mockResolvedValue({ data: null, error: { message: 'auth down' } }); + builder.mockReturnValue({ + data: [ + { activity_id: 'a1', user_id: 'test-user-id', reaction: 'fire' }, + ], + error: null, + }); + + const result = await getBatchReactions(supabase, ['a1']); + expect(result.reactionsMap.a1.counts).toEqual({ fire: 1 }); + expect(result.reactionsMap.a1.userReactions).toEqual([]); + }); });