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

## Summary

| Metric | Before | After |
|--------|--------|-------|
| Statements | 79.86% | 88.57% |
| Branches | 69.44% | 76.58% |
| Functions | 67.1% | 80.73% |
| Lines | 83.33% | 91.67% |

Total tests: 187 → 261 (+74 new tests).

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

- **Statements:** 74.9% → 79.86% → 88.57% (cumulative +13.67pp over two weeks)
- **Lines:** 78.05% → 83.33% → 91.67% (cumulative +13.62pp)
- **Functions:** 62.5% → 67.1% → 80.73% (cumulative +18.23pp)
- Files untested last week now covered: `comments.js` (0% → 96.36%), `nudges.js` (0% → 100%), `cronAuth.js` (0% → 92.3%), `partnerships.js` (0% → 86.66%), `onboarding.js` (0% → 87.5%)
- Files that remained untested across both weeks and are now covered: `partnerships.js`, `onboarding.js` (were deferred in the 04-12 report due to mock limitations — resolved this week by adding `limit`, `or`, `upsert` to the mock helper)
- `streaks.js` was highlighted as mid-range (49%) last week — now at 91.5%

## 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/streaks.test.js` — extended with 15 new tests, covers: `formatDateInTimezone`, `getHourInTimezone`, `getActivityHeatmap` (plus existing `formatUTCDate`, `calculateStreak`)
- `tests/unit/lib/comments.test.js` — 12 tests, covers: `getComments`, `getBatchCommentCounts`, `postComment`, `deleteComment`
- `tests/unit/lib/nudges.test.js` — 4 tests, covers: `sendNudge`
- `tests/unit/lib/cronAuth.test.js` — 6 tests, covers: `verifyCronSecret`
- `tests/unit/lib/partnerships.test.js` — 18 tests, covers: `sendPartnerRequest`, `acceptPartnerRequest`, `declinePartnerRequest`, `removePartnership`, `getPartnerships`, `notifyPartner`
- `tests/unit/lib/onboarding.test.js` — 12 tests, covers: `getOnboardingState`, `detectProgress`, `syncProgress`, `resetOnboarding`

## Mock Helper Improvements

- Added `limit`, `or`, `upsert` to `createMockSupabase()` chain methods
- Exported new `createTableMock()` from `tests/setup/supabase-mock.js` for per-table builder control
- Refactored `streaks-advanced.test.js` to import shared `createTableMock` instead of a local copy

## 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` — `'use client'` component with heavy DOM/requestAnimationFrame usage
- `lib/sounds.js` — depends on Web Audio API (AudioContext), not available in happy-dom
- `lib/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks
- `lib/email.js` — only export (`sendReminderEmail`) requires mocking the Resend SDK module; deferred to a future week

## Per-File Coverage Detail

| File | Statements | Branches | Functions | Lines |
|------|-----------|----------|-----------|-------|
| accentColors.js | 100% | 100% | 100% | 100% |
| activity.js | 77.58% | 59.55% | 29.16% | 88.04% |
| comments.js | 96.36% | 79.41% | 100% | 95.55% |
| cronAuth.js | 92.3% | 87.5% | 100% | 92.3% |
| gamification.js | 98.07% | 85.18% | 100% | 97.72% |
| notifications.js | 87.27% | 85.29% | 83.33% | 88% |
| nudges.js | 100% | 83.33% | 100% | 100% |
| onboarding.js | 87.5% | 85.71% | 100% | 86.53% |
| pactTemplates.js | 100% | 100% | 100% | 100% |
| partnerships.js | 86.66% | 73.11% | 100% | 92.78% |
| reactions.js | 90.47% | 81.25% | 100% | 96.36% |
| streaks-advanced.js | 84.12% | 74.11% | 100% | 86.32% |
| streaks.js | 91.5% | 84.31% | 90.9% | 93.61% |

## Notes

- The `createTableMock()` pattern (per-table builders) unlocked testing for `partnerships.js` and `onboarding.js`, which were previously deferred due to `.or()`, `.limit()`, and `.upsert()` not being supported in the mock.
- `partnerships.js` branches at 73.11% — the remaining uncovered branches are in `notifyPartner`'s multi-partnership notification assembly and profile-fetch error logging paths. These require more granular mock state than `mockReturnValueSequence` easily supports.
- `activity.js` remains at 77.58% statements with only 29.16% function coverage — the Supabase-heavy functions (`logActivity`, `getGroupActivity`, `getAllActivity`, `getGroupStats`) need the per-table mock pattern for full coverage. Candidate for next week.
- `email.js` still at 0% — requires Resend SDK module mocking (`vi.mock('resend', ...)`). Candidate for next week.
- `cronAuth.js` line 36 (`timingSafeEqual` call) shows as uncovered in v8 reporting despite the "wrong token" test exercising it — likely a v8 coverage instrumentation artifact around `Buffer.from` + `timingSafeEqual`.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 48 additions & 1 deletion tests/setup/supabase-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export function createMockSupabase() {
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
limit: vi.fn(),
or: vi.fn(),
upsert: vi.fn(),

// Set what the final awaited value resolves to
mockReturnValue(value) {
Expand Down Expand Up @@ -58,7 +61,7 @@ export function createMockSupabase() {
const chainMethods = [
'select', 'eq', 'neq', 'in', 'not', 'gte',
'order', 'range', 'single', 'maybeSingle',
'insert', 'update', 'delete',
'insert', 'update', 'delete', 'limit', 'or', 'upsert',
];
chainMethods.forEach((method) => {
builder[method].mockReturnValue(builder);
Expand Down Expand Up @@ -99,3 +102,47 @@ export function createMockSupabase() {

return { supabase, builder };
}

/**
* Create a per-table mock where each `from(table)` call returns a
* dedicated builder. Use this when the function-under-test reads from
* multiple tables and we want distinct responses per table.
*
* Force-create a builder by calling `supabase.from('tableName')` before
* the function-under-test, then configure with `.resolveWith(value)`.
*/
export function createTableMock() {
function makeBuilder() {
const methods = [
'select', 'eq', 'neq', 'in', 'not', 'gte',
'order', 'range', 'single', 'maybeSingle',
'insert', 'update', 'delete', 'limit', 'or', 'upsert',
];
const b = {
resolveWith(value) {
b.then = (resolve) => resolve(value);
},
};
methods.forEach((m) => {
b[m] = vi.fn(() => b);
});
b.resolveWith({ data: null, error: null });
return b;
}

const builders = {};
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 };
}
164 changes: 164 additions & 0 deletions tests/unit/lib/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { getComments, getBatchCommentCounts, postComment, deleteComment } from '@/lib/comments';
import { createMockSupabase } from '../../setup/supabase-mock';

describe('getComments', () => {
it('returns comments with user profiles attached', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [
{ id: 'c1', user_id: 'u1', activity_id: 'a1', comment_text: 'hello', created_at: '2024-06-15T12:00:00Z' },
],
error: null,
},
{
data: [{ id: 'u1', full_name: 'Alice', avatar_url: 'pic.jpg' }],
error: null,
},
]);

const result = await getComments(supabase, 'a1');

expect(result.error).toBeNull();
expect(result.data).toHaveLength(1);
expect(result.data[0].comment_text).toBe('hello');
expect(result.data[0].user).toEqual({ id: 'u1', full_name: 'Alice', avatar_url: 'pic.jpg' });
});

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('falls back to Unknown user when profile not found', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [{ id: 'c1', user_id: 'u-missing', activity_id: 'a1', comment_text: 'hi', created_at: '2024-06-15' }],
error: null,
},
{ data: [], error: null },
]);

const result = await getComments(supabase, 'a1');

expect(result.data[0].user).toEqual({ full_name: 'Unknown', avatar_url: null });
});

it('returns empty data 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();
});
});

describe('getBatchCommentCounts', () => {
it('returns counts grouped by 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({});
});
});

describe('postComment', () => {
it('returns success when commenting on own activity (no notification)', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', activity_id: 'a1', user_id: 'test-user-id', comment_text: 'nice' }, error: null },
{ data: { user_id: 'test-user-id' }, error: null },
]);

const result = await postComment(supabase, 'a1', 'nice');

expect(result.success).toBe(true);
expect(result.data.id).toBe('c1');
});

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', 'hi');

expect(result.success).toBe(false);
expect(result.error).toBe('Not authenticated');
});

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

const result = await postComment(supabase, 'a1', 'hi');

expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});

describe('deleteComment', () => {
it('returns success on delete', 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 DB 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);
expect(result.error).toBeTruthy();
});
});
73 changes: 73 additions & 0 deletions tests/unit/lib/cronAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { verifyCronSecret } from '@/lib/cronAuth';

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

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

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

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

it('returns authorized for correct Bearer token', () => {
const request = makeRequest('Bearer test-secret-123');
const result = verifyCronSecret(request);

expect(result.authorized).toBe(true);
expect(result.response).toBeUndefined();
});

it('returns unauthorized when CRON_SECRET is not set', () => {
delete process.env.CRON_SECRET;
const request = makeRequest('Bearer anything');
const result = verifyCronSecret(request);

expect(result.authorized).toBe(false);
});

it('returns unauthorized when authorization header is missing', () => {
const request = makeRequest(null);
const result = verifyCronSecret(request);

expect(result.authorized).toBe(false);
});

it('returns unauthorized for wrong token', () => {
const request = makeRequest('Bearer wrong-secret');
const result = verifyCronSecret(request);

expect(result.authorized).toBe(false);
});

it('returns unauthorized when token length differs', () => {
const request = makeRequest('Bearer short');
const result = verifyCronSecret(request);

expect(result.authorized).toBe(false);
});

it('returns unauthorized for non-Bearer scheme', () => {
const request = makeRequest('Basic test-secret-123');
const result = verifyCronSecret(request);

expect(result.authorized).toBe(false);
});
});
Loading
Loading