Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
59 changes: 59 additions & 0 deletions docs/reports/coverage-2026-06-14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Coverage Report — 2026-06-14

## Summary

| Metric | Before | After |
|--------|--------|-------|
| Statements | 79.86% | 89.38% |
| Branches | 69.44% | 77.35% |
| Functions | 67.1% | 76.92% |
| Lines | 83.33% | 92.53% |

Total tests: 187 → 233 (+46 new tests).

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

| Metric | 2026-04-12 | 2026-06-14 | Change |
|--------|-----------|-----------|--------|
| Statements | 74.9% | 89.38% | +14.48% |
| Branches | 67.66% | 77.35% | +9.69% |
| Functions | 62.5% | 76.92% | +14.42% |
| Lines | 78.05% | 92.53% | +14.48% |

- Files that were untested last report and are now covered: `comments.js` (0% → 100%), `nudges.js` (0% → 100%), `cronAuth.js` (0% → 100%), `email.js` (0% → 95.23%)
- `streaks.js` improved from 49% → 91.5% statements
- Files that remain untested across multiple weeks: `onboarding.js`, `partnerships.js`, `confetti.js`, `sounds.js`

## Files Changed (last 7 days)

No lib/ files changed in the last 7 days. Targeted lowest-coverage and untested utility files instead.

## New Tests Written

- `tests/unit/lib/streaks.test.js` — extended with 14 new tests, now covers: `formatUTCDate`, `formatDateInTimezone`, `getHourInTimezone`, `calculateStreak`, `getActivityHeatmap`
- `tests/unit/lib/comments.test.js` — 14 tests, covers: `getComments`, `getBatchCommentCounts`, `postComment`, `deleteComment`
- `tests/unit/lib/nudges.test.js` — 5 tests, covers: `sendNudge` (auth, rate limiting, happy path, insert error, missing profile)
- `tests/unit/lib/cronAuth.test.js` — 6 tests, covers: `verifyCronSecret` (valid token, missing header, missing env, wrong token, wrong length, empty header)
- `tests/unit/lib/email.test.js` — 6 tests, covers: `sendReminderEmail` (happy path, Resend error, network throw, correct recipient, missing userName, XSS escaping)

## Mock Helper Update

- Added `limit` to `tests/setup/supabase-mock.js` chain methods, enabling tests for `nudges.js` (which was previously blocked by the missing `.limit()` support).

## 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/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks (require component rendering)
- `lib/pactTemplates.js`, `lib/accentColors.js` — already at 100% coverage
- `lib/onboarding.js` — requires `.upsert()` mock support (deferred)
- `lib/partnerships.js` — requires `.or()` and multi-chain builder states (deferred)
- `lib/confetti.js`, `lib/sounds.js` — browser-only side-effect modules (canvas/audio APIs)

## Notes

- `streaks.js` uncovered line 131 is a `break` inside the current-streak loop when the gap is >1 day; hard to reach since the mock resolves both concurrent queries to the same value (would need per-call builder state). Lines 18-21 are the unexported `formatLocalDate` helper.
- `activity.js` function coverage remains at 29% because `fetchProfilesMap` (private), `isTestActivity` (private), and the full happy paths of `getGroupActivity`/`getAllActivity`/`getGroupStats` involve multiple sequential Supabase queries with profile/reaction assembly that the shared mock can't differentiate per-table. The exported functions are tested at their error and empty-data boundaries.
- `cronAuth.js` reached 100% coverage by adding a same-length-different-value token test that exercises the `timingSafeEqual` false branch.
- Next week's candidates for coverage improvement: `onboarding.js` and `partnerships.js` (require mock helper extension for `.upsert()` and `.or()`).
3 changes: 2 additions & 1 deletion tests/setup/supabase-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function createMockSupabase() {
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
limit: vi.fn(),

// Set what the final awaited value resolves to
mockReturnValue(value) {
Expand Down Expand Up @@ -58,7 +59,7 @@ export function createMockSupabase() {
const chainMethods = [
'select', 'eq', 'neq', 'in', 'not', 'gte',
'order', 'range', 'single', 'maybeSingle',
'insert', 'update', 'delete',
'insert', 'update', 'delete', 'limit',
];
chainMethods.forEach((method) => {
builder[method].mockReturnValue(builder);
Expand Down
176 changes: 176 additions & 0 deletions tests/unit/lib/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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', activity_id: 'a1', user_id: 'u1', comment_text: 'Nice!', created_at: '2024-06-15T12:00:00Z' },
],
error: null,
},
{
data: [{ id: 'u1', full_name: 'Alice', avatar_url: 'alice.png' }],
error: null,
},
]);

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

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 and error on DB failure', 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 "Unknown" for missing profiles', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [{ id: 'c1', activity_id: 'a1', user_id: 'u-missing', 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', 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', 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: '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 not authenticated when no user', async () => {
const { supabase } = createMockSupabase();
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null });

const result = await postComment(supabase, 'a1', 'Hello');
expect(result).toEqual({ success: false, error: 'Not authenticated' });
});

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 },
{ data: null, error: null },
]);

const result = await postComment(supabase, 'a1', 'Hello');
expect(result.success).toBe(true);
expect(result.data).toBeTruthy();
});

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);
expect(result.error).toBeTruthy();
});

it('truncates long comments in notification', async () => {
const { supabase, builder } = createMockSupabase();
const longText = 'A'.repeat(100);
builder.mockReturnValueSequence([
{ data: { id: 'c1', comment_text: longText }, error: null },
{ data: { user_id: 'other-user' }, error: null },
{ data: null, error: null },
]);

const result = await postComment(supabase, 'a1', longText);
expect(result.success).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert the truncated notification payload

When notification truncation regresses and postComment sends the complete 100-character comment, this test still passes because it only checks the function's success result. Inspect the notification builder.insert call and assert that its message contains exactly 60 characters plus the ellipsis so the behavior named by the test is protected.

Useful? React with 👍 / 👎.

});
});

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

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

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).toEqual({ success: true });
});

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

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

beforeEach(() => {
process.env = { ...ORIGINAL_ENV, CRON_SECRET: 'my-secret-123' };
});

afterEach(() => {
process.env = ORIGINAL_ENV;
});

it('returns authorized for correct bearer token', () => {
const req = { headers: { get: (key) => key === 'authorization' ? 'Bearer my-secret-123' : null } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(true);
expect(result.response).toBeUndefined();
});

it('returns unauthorized when no authorization header', () => {
const req = { headers: { get: () => null } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(false);
expect(result.response).toBeTruthy();
});

it('returns unauthorized when CRON_SECRET is not set', () => {
delete process.env.CRON_SECRET;
const req = { headers: { get: () => 'Bearer something' } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(false);
});

it('returns unauthorized for token with different length', () => {
const req = { headers: { get: () => 'Bearer short' } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(false);
});

it('returns unauthorized for wrong token of same length', () => {
const req = { headers: { get: () => 'Bearer my-secret-456' } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(false);
});

it('returns unauthorized when header is empty string', () => {
const req = { headers: { get: () => '' } };

const result = verifyCronSecret(req);
expect(result.authorized).toBe(false);
});
});
Loading
Loading