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
68 changes: 68 additions & 0 deletions docs/reports/coverage-2026-05-10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Coverage Report — 2026-05-10

## Summary

| Metric | Before | After |
|--------|--------|-------|
| Statements | 79.86% | 89.32% |
| Branches | 69.44% | 76.68% |
| Functions | 67.1% | 79.41% |
| Lines | 83.33% | 92.36% |

Total tests: 187 → 268 (+81 new tests).

## Week-over-Week (vs 2026-04-12)

| Metric | Apr 12 | May 10 | Delta |
|--------|--------|--------|-------|
| Statements | 74.9% | 89.32% | +14.42% |
| Branches | 67.66% | 76.68% | +9.02% |
| Functions | 62.5% | 79.41% | +16.91% |
| Lines | 78.05% | 92.36% | +14.31% |

**Files that were untested last week and are now covered:**
- `lib/comments.js` — 0% → 100% statements
- `lib/cronAuth.js` — 0% → 92.3% statements
- `lib/nudges.js` — 0% → 100% statements
- `lib/onboarding.js` — 0% → 91.07% statements (was deferred due to mock limitations)
- `lib/partnerships.js` — 0% → 91.66% statements (was deferred due to mock limitations)

**Files that remain untested across multiple weeks (flagged):**
- `lib/email.js` — 0% (only export depends on Resend SDK; internal helpers not exported)
- `lib/confetti.js` — 0% (browser-only DOM/animation code)
- `lib/sounds.js` — 0% (browser-only Web Audio API code)

## 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/comments.test.js` — 14 tests, covers: `getComments`, `getBatchCommentCounts`, `postComment`, `deleteComment`
- `tests/unit/lib/cronAuth.test.js` — 7 tests, covers: `verifyCronSecret`
- `tests/unit/lib/nudges.test.js` — 4 tests, covers: `sendNudge`
- `tests/unit/lib/onboarding.test.js` — 14 tests, covers: `getOnboardingState`, `detectProgress`, `syncProgress`, `resetOnboarding`
- `tests/unit/lib/partnerships.test.js` — 18 tests, covers: `sendPartnerRequest`, `acceptPartnerRequest`, `declinePartnerRequest`, `removePartnership`, `getPartnerships`, `notifyPartner`
- `tests/unit/lib/streaks.test.js` — extended with 24 new tests, now covers: `formatDateInTimezone`, `getHourInTimezone`, `getActivityHeatmap`, plus additional `calculateStreak` edge cases (yesterday streak, single completion, count from second query)

## Mock Helper Updates

- Added `limit`, `upsert`, and `or` as chainable methods to `tests/setup/supabase-mock.js`
- This unblocked `onboarding.js` and `partnerships.js` testing, which were deferred in the April 12 report

## 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` — browser-only DOM manipulation (requires full browser environment)
- `lib/sounds.js` — browser-only Web Audio API (requires AudioContext)
- `lib/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks (component-like)
- `lib/email.js` — only export (`sendReminderEmail`) depends on the `Resend` SDK; internal helpers (`escapeHtml`, `generateReminderEmailHtml`) are not exported

## Notes

- `streaks.js` saw the biggest improvement: 49.05% → 89.62% statements. The remaining uncovered lines (18-21, 131, 209, 248) are the private `formatLocalDate` function (not exported, never imported) and some deep branches in `getActivityHeatmap`.
- `cronAuth.js` line 36 (`timingSafeEqual` false branch) is technically covered by the "wrong token" test, but v8 reports it as uncovered because the length-mismatch guard on line 28 returns early for most wrong tokens. Only tokens of exactly the right length reach `timingSafeEqual`.
- `partnerships.assertUuid` internal helper throws on non-UUID strings — this is tested implicitly through the `sendPartnerRequest` self-partner test (UUIDs pass validation) and would need a non-UUID input test to exercise the throw path.
- `activity.js` remains at 29.16% function coverage — the private `fetchProfilesMap` and `isTestActivity` helpers are not directly testable, and the `getGroupActivity`/`getAllActivity` happy paths require multi-query mock sequences that would need per-call builder state. Candidate for future mock helper improvement.
7 changes: 5 additions & 2 deletions tests/setup/supabase-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export function createMockSupabase() {
maybeSingle: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
or: vi.fn(),
limit: vi.fn(),

// Set what the final awaited value resolves to
mockReturnValue(value) {
Expand Down Expand Up @@ -57,8 +60,8 @@ export function createMockSupabase() {
// Every method returns the builder for chaining
const chainMethods = [
'select', 'eq', 'neq', 'in', 'not', 'gte',
'order', 'range', 'single', 'maybeSingle',
'insert', 'update', 'delete',
'order', 'range', 'limit', 'single', 'maybeSingle',
'insert', 'update', 'upsert', 'delete', 'or',
];
chainMethods.forEach((method) => {
builder[method].mockReturnValue(builder);
Expand Down
172 changes: 172 additions & 0 deletions tests/unit/lib/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from 'vitest';
import {
getComments,
getBatchCommentCounts,
postComment,
deleteComment,
} from '@/lib/comments';
import { createMockSupabase } from '../../setup/supabase-mock';

vi.mock('@/lib/notifications', () => ({
createNotification: vi.fn().mockResolvedValue({ success: true }),
NOTIFICATION_TYPES: { COMMENT_ON_ACTIVITY: 'comment_on_activity' },
}));

describe('getComments', () => {
it('returns comments with user profiles attached', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [
{ id: 'c1', activity_id: 'a1', user_id: 'u1', comment_text: 'Nice!' },
{ id: 'c2', activity_id: 'a1', user_id: 'u2', comment_text: 'Great work' },
],
error: null,
},
{
data: [
{ id: 'u1', full_name: 'Alice', avatar_url: 'alice.png' },
{ id: 'u2', full_name: 'Bob', avatar_url: null },
],
error: null,
},
]);

const result = await getComments(supabase, 'a1');
expect(result.error).toBeNull();
expect(result.data).toHaveLength(2);
expect(result.data[0].user.full_name).toBe('Alice');
expect(result.data[1].user.full_name).toBe('Bob');
});

it('returns empty array when no comments exist', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: [], error: null });

const result = await getComments(supabase, 'a1');
expect(result.data).toEqual([]);
expect(result.error).toBeNull();
});

it('returns empty array on DB error', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: { message: 'DB error' } });

const result = await getComments(supabase, 'a1');
expect(result.data).toEqual([]);
expect(result.error).toBeTruthy();
});

it('uses fallback profile for unknown user IDs', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [{ id: 'c1', activity_id: 'a1', user_id: 'unknown-user', comment_text: 'hi' }],
error: null,
},
{ data: [], error: null },
]);

const result = await getComments(supabase, 'a1');
expect(result.data[0].user.full_name).toBe('Unknown');
expect(result.data[0].user.avatar_url).toBeNull();
});
});

describe('getBatchCommentCounts', () => {
it('returns counts per activity ID', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({
data: [
{ activity_id: 'a1' },
{ activity_id: 'a1' },
{ activity_id: 'a2' },
],
error: null,
});

const result = await getBatchCommentCounts(supabase, ['a1', 'a2']);
expect(result).toEqual({ a1: 2, a2: 1 });
});

it('returns empty object for empty activityIds array', async () => {
const { supabase } = createMockSupabase();

const result = await getBatchCommentCounts(supabase, []);
expect(result).toEqual({});
});

it('returns empty object on DB error', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: { message: 'DB error' } });

const result = await getBatchCommentCounts(supabase, ['a1']);
expect(result).toEqual({});
});

it('handles null data gracefully', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: null });

const result = await getBatchCommentCounts(supabase, ['a1']);
expect(result).toEqual({});
});
});

describe('postComment', () => {
it('returns success on happy path', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', comment_text: 'hello' }, error: null },
{ data: { user_id: 'other-user' }, error: null },
]);

const result = await postComment(supabase, 'a1', 'hello');
expect(result.success).toBe(true);
expect(result.data).toEqual({ id: 'c1', comment_text: 'hello' });
});

it('returns failure when not authenticated', async () => {
const { supabase } = createMockSupabase();
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null });

const result = await postComment(supabase, 'a1', 'hello');
expect(result.success).toBe(false);
expect(result.error).toBe('Not authenticated');
});

it('returns failure on insert error', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: { message: 'insert failed' } });

const result = await postComment(supabase, 'a1', 'hello');
expect(result.success).toBe(false);
});
});

describe('deleteComment', () => {
it('returns success on happy path', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: null });

const result = await deleteComment(supabase, 'c1');
expect(result.success).toBe(true);
});

it('returns failure when not authenticated', async () => {
const { supabase } = createMockSupabase();
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null });

const result = await deleteComment(supabase, 'c1');
expect(result.success).toBe(false);
expect(result.error).toBe('Not authenticated');
});

it('returns failure on delete error', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: null, error: { message: 'delete failed' } });

const result = await deleteComment(supabase, 'c1');
expect(result.success).toBe(false);
});
});
77 changes: 77 additions & 0 deletions tests/unit/lib/cronAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { verifyCronSecret } from '@/lib/cronAuth';

describe('verifyCronSecret', () => {
const originalEnv = process.env.CRON_SECRET;

beforeEach(() => {
process.env.CRON_SECRET = 'test-secret-123';
});

afterEach(() => {
if (originalEnv !== undefined) {
process.env.CRON_SECRET = originalEnv;
} else {
delete process.env.CRON_SECRET;
}
});

function makeRequest(authHeader) {
return {
headers: {
get: vi.fn((name) => {
if (name === 'authorization') return authHeader;
return null;
}),
},
};
}

it('returns authorized: true for valid Bearer token', () => {
const request = makeRequest('Bearer test-secret-123');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(true);
expect(result.response).toBeUndefined();
});

it('returns authorized: false when authorization header is missing', () => {
const request = makeRequest(null);
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});

it('returns authorized: false when CRON_SECRET env var is not set', () => {
delete process.env.CRON_SECRET;
const request = makeRequest('Bearer anything');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
});

it('returns authorized: false for wrong token', () => {
const request = makeRequest('Bearer wrong-secret');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
});

it('returns authorized: false for token with wrong prefix', () => {
const request = makeRequest('Basic test-secret-123');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
});

it('returns authorized: false for empty authorization header', () => {
const request = makeRequest('');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
});

it('returns a Response with 401 status on failure', async () => {
const request = makeRequest('Bearer wrong');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
const body = await result.response.json();
expect(body).toEqual({ error: 'Unauthorized' });
expect(result.response.status).toBe(401);
});
});
54 changes: 54 additions & 0 deletions tests/unit/lib/nudges.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import { sendNudge } from '@/lib/nudges';
import { createMockSupabase } from '../../setup/supabase-mock';

vi.mock('@/lib/notifications', () => ({
createNotification: vi.fn().mockResolvedValue({ success: true }),
NOTIFICATION_TYPES: { NUDGE_RECEIVED: 'nudge_received' },
}));

describe('sendNudge', () => {
it('returns success when nudge is sent', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: [], error: null },
{ data: null, error: null },
{ data: { full_name: 'Alice' }, error: null },
]);

const result = await sendNudge(supabase, 'target-user-id');
expect(result.success).toBe(true);
});

it('returns failure when not authenticated', async () => {
const { supabase } = createMockSupabase();
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null });

const result = await sendNudge(supabase, 'target-user-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Not authenticated');
});

it('returns rate limit error when nudged recently', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({
data: [{ id: 'existing-nudge' }],
error: null,
});

const result = await sendNudge(supabase, 'target-user-id');
expect(result.success).toBe(false);
expect(result.error).toBe('You can only nudge this person once per hour');
});

it('returns failure on insert error', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: [], error: null },
{ data: null, error: { message: 'Insert failed' } },
]);

const result = await sendNudge(supabase, 'target-user-id');
expect(result.success).toBe(false);
});
});
Loading
Loading