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

## Summary

| Metric | Before | After |
|--------|--------|-------|
| Statements | 79.86% | 89.50% |
| Branches | 69.44% | 77.29% |
| Functions | 67.10% | 75.58% |
| Lines | 83.33% | 92.63% |

Total tests: 187 → 233.

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

| Metric | Apr 12 | May 3 | Change |
|--------|--------|-------|--------|
| Statements | 74.90% | 89.50% | +14.60% |
| Branches | 67.66% | 77.29% | +9.63% |
| Functions | 62.50% | 75.58% | +13.08% |
| Lines | 78.05% | 92.63% | +14.58% |
| Tests | 183 | 233 | +50 |

**Files untested last week, now covered:**
- `lib/comments.js` — 0% → 100%
- `lib/nudges.js` — 0% → 100%
- `lib/cronAuth.js` — 0% → 100%

**Files that remain untested across multiple weeks:**
- `lib/onboarding.js` — 0% (requires `.limit()` / `.upsert()` mock support; deferred since Apr 12)
- `lib/partnerships.js` — 0% (requires `.or()` and multi-chain builder states; deferred since Apr 12)
- `lib/confetti.js` — 0% (client-only DOM/animation code)
- `lib/sounds.js` — 0% (requires Web Audio API)
- `lib/email.js` — 0% (only export requires Resend SDK; internal helpers not exported)

## Files Changed (last 7 days)

No `lib/` files were modified in the last 7 days. Fell back to lowest-coverage files:

- `lib/streaks.js` — 49.05% → 93.39% statements (highest impact)
- `lib/comments.js` — 0% → 100% statements (new test file)
- `lib/nudges.js` — 0% → 100% statements (new test file)
- `lib/cronAuth.js` — 0% → 100% statements (new test file)

## New Tests Written

- `tests/unit/lib/streaks.test.js` — extended with 18 new tests, now covers: `formatDateInTimezone`, `getHourInTimezone`, `getActivityHeatmap`, plus additional `calculateStreak` edge cases (yesterday streak, longest vs current streak)
- `tests/unit/lib/comments.test.js` — 15 tests, covers: `getComments`, `getBatchCommentCounts`, `postComment`, `deleteComment`
- `tests/unit/lib/nudges.test.js` — 5 tests, covers: `sendNudge` (auth, rate limiting, happy path, errors)
- `tests/unit/lib/cronAuth.test.js` — 7 tests, covers: `verifyCronSecret` (valid token, missing header, missing env, wrong token, wrong length, empty values, missing Bearer prefix)

## 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` — client-only DOM manipulation with `requestAnimationFrame`; requires browser environment.
- `lib/sounds.js` — requires Web Audio API (`AudioContext`); not available in happy-dom.
- `lib/email.js` — only public export (`sendReminderEmail`) requires the Resend SDK. Internal helpers (`generateReminderEmailHtml`, `escapeHtml`) are not exported. Would need Resend module mock or export refactor.
- `lib/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks (out of scope per rules).
- `lib/onboarding.js`, `lib/partnerships.js` — require mock helper extensions (`.limit()`, `.or()`, `.upsert()`, per-call chain state). Deferred again; per-table mock pattern could address these in a future week.
- `lib/pactTemplates.js`, `lib/accentColors.js` — already at 100% coverage.
- `lib/gamification.js` — already at 98.07% (one uncovered line).
- `lib/activity.js` — 77.58% statements; remaining gaps are in `fetchProfilesMap` internal + `getAllActivity` test-data filtering paths that need per-table mock support.

## Notes

- `streaks.js` had the largest single-file improvement (49% → 93%). The remaining uncovered code is `formatLocalDate` (lines 18-21), which is a non-exported internal function — impossible to test directly.
- `nudges.test.js` uses a local `createTableMock` helper (copied from `streaks-advanced.test.js`) to support `.limit()` chaining required by `sendNudge`'s rate-limit query.
- `comments.js` branches at 88.23% — the uncovered branches are profile-enrichment paths (lines 18, 27-32, 105) where the mock resolves all queries to the same value, making it hard to simulate "profiles fetch returns different data from comments fetch" in a single test.
- `cronAuth.js` reached 100% coverage including the `timingSafeEqual` branch (different-length and same-length rejection paths).
- Next priority files for future weeks: `lib/email.js` (export refactor or Resend mock), `lib/onboarding.js` and `lib/partnerships.js` (mock helper improvements).
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.

189 changes: 189 additions & 0 deletions tests/unit/lib/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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 work!' },
{ id: 'c2', activity_id: 'a1', user_id: 'u2', comment_text: 'Thanks!' },
],
error: null,
},
{
data: [
{ id: 'u1', full_name: 'Alice', avatar_url: 'https://example.com/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');
expect(result.data[0].comment_text).toBe('Nice work!');
});

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.error).toBeNull();
expect(result.data).toEqual([]);
});

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('assigns Unknown user when profile is missing', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [{ id: 'c1', activity_id: 'a1', user_id: 'u-missing', comment_text: 'Hello' }],
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 grouped by activity_id', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({
data: [
{ activity_id: 'a1' },
{ activity_id: 'a1' },
{ activity_id: 'a2' },
],
error: null,
});

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

it('returns empty object for empty activity IDs array', async () => {
const { supabase } = createMockSupabase();
const counts = await getBatchCommentCounts(supabase, []);
expect(counts).toEqual({});
});

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

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

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

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

describe('postComment', () => {
it('returns not authenticated when user is null', 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 success on happy path', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', comment_text: 'Great!' }, error: null },
{ data: { user_id: 'other-user' }, error: null },
]);

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

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('skips notification when commenter is the activity author', async () => {
const { createNotification } = await import('@/lib/notifications');
createNotification.mockClear();

const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', comment_text: 'Self-comment' }, error: null },
{ data: { user_id: 'test-user-id' }, error: null },
]);

const result = await postComment(supabase, 'a1', 'Self-comment');
expect(result.success).toBe(true);
expect(createNotification).not.toHaveBeenCalled();
});
});

describe('deleteComment', () => {
it('returns not authenticated when user is null', 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 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 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();
});
});
70 changes: 70 additions & 0 deletions tests/unit/lib/cronAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { verifyCronSecret } from '@/lib/cronAuth';

function mockRequest(authHeader) {
return {
headers: {
get: (name) => (name === 'authorization' ? authHeader : null),
},
};
}

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

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

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

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

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

it('returns unauthorized when CRON_SECRET env var is not set', () => {
delete process.env.CRON_SECRET;
const result = verifyCronSecret(mockRequest('Bearer some-token'));
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});

it('returns unauthorized for incorrect token of same length', () => {
const result = verifyCronSecret(mockRequest('Bearer test-secret-999'));
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});

it('returns unauthorized for token with different length', () => {
const result = verifyCronSecret(mockRequest('Bearer short'));
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});

it('returns unauthorized when both header and secret are empty strings', () => {
process.env.CRON_SECRET = '';
const result = verifyCronSecret(mockRequest(''));
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});

it('returns unauthorized for missing Bearer prefix', () => {
const result = verifyCronSecret(mockRequest('test-secret-123'));
expect(result.authorized).toBe(false);
expect(result.response).toBeDefined();
});
});
Loading
Loading