Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/reports/coverage-2026-08-23.md
Original file line number Diff line number Diff line change
@@ -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.
200 changes: 200 additions & 0 deletions tests/unit/lib/activity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
43 changes: 43 additions & 0 deletions tests/unit/lib/notifications.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,56 @@ import { describe, it, expect } from 'vitest';
import {
NOTIFICATION_TYPES,
getNotificationIcon,
getNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
createNotification,
} 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');
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/lib/reactions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
Loading