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

## Summary

| Metric | Before | After |
|--------|--------|-------|
| Statements | 79.86% | 89% |
| Branches | 69.44% | 75.67% |
| Functions | 67.1% | 79.43% |
| Lines | 83.33% | 92.15% |

Total tests: 187 → 273.

## Week-over-Week

Previous report: 2026-04-12 (74.9% → 79.86% statements, 183 → 187 tests).

- **Coverage (statements):** 79.86% → 89% (+9.14pp)
- **Files untested last week, now covered:** `comments.js`, `cronAuth.js`, `email.js`, `nudges.js`, `onboarding.js`, `partnerships.js`
- **Files that remain untested:** `confetti.js` (React hooks + DOM), `sounds.js` (Web Audio API), `lib/supabase/*` (client config) — all out of scope per task rules

## Files Changed (last 7 days)

No `lib/` files changed in the last 7 days. Fell back to lowest-coverage files overall.

## New Tests Written

- `tests/unit/lib/streaks.test.js` — expanded with 12 new tests, now covers: `formatUTCDate`, `formatDateInTimezone`, `getHourInTimezone`, `calculateStreak` (5 scenarios), `getActivityHeatmap` (5 scenarios)
- `tests/unit/lib/comments.test.js` — 17 tests, covers: `getComments`, `getBatchCommentCounts`, `postComment`, `deleteComment`
- `tests/unit/lib/cronAuth.test.js` — 7 tests, covers: `verifyCronSecret` (valid token, missing header, missing env, wrong token, length mismatch, missing prefix, empty strings)
- `tests/unit/lib/email.test.js` — 7 tests, covers: `sendReminderEmail` (happy path, Resend error, exception, HTML escaping for XSS, missing userName, subject line)
- `tests/unit/lib/nudges.test.js` — 5 tests, covers: `sendNudge` (happy path, unauthenticated, rate limited, insert error, exception)
- `tests/unit/lib/partnerships.test.js` — 22 tests, covers: `sendPartnerRequest`, `acceptPartnerRequest`, `declinePartnerRequest`, `removePartnership`, `getPartnerships`, `notifyPartner`
- `tests/unit/lib/onboarding.test.js` — 12 tests, covers: `getOnboardingState`, `syncProgress`, `resetOnboarding`

## Mock Helper Update

- Added `or`, `limit`, `upsert`, `lte`, `gt`, `lt` to `tests/setup/supabase-mock.js` chain methods, enabling tests for `nudges.js`, `partnerships.js`, and `onboarding.js` which were previously blocked by missing mock methods.

## 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` — React hooks (`useConfetti`) + DOM APIs (`document.createElement`, `requestAnimationFrame`)
- `lib/sounds.js` — Web Audio API (`AudioContext`) not available in happy-dom
- `lib/useKeyboardShortcuts.js`, `lib/useModalScrollLock.js` — React hooks

## Per-File Coverage Detail

| File | Before | After |
|------|--------|-------|
| `accentColors.js` | 100% | 100% |
| `activity.js` | 77.58% | 77.58% |
| `comments.js` | 0% | 100% |
| `cronAuth.js` | 0% | 92.3% |
| `email.js` | 0% | 95.23% |
| `gamification.js` | 98.07% | 98.07% |
| `notifications.js` | 87.27% | 87.27% |
| `nudges.js` | 0% | 100% |
| `onboarding.js` | 0% | 78.57% |
| `pactTemplates.js` | 100% | 100% |
| `partnerships.js` | 0% | 91.66% |
| `reactions.js` | 90.47% | 90.47% |
| `streaks-advanced.js` | 84.12% | 84.12% |
| `streaks.js` | 49.05% | 92.45% |

## Notes

- `streaks.js` jumped from 49% to 92.45% by adding tests for `formatDateInTimezone`, `getHourInTimezone`, and `getActivityHeatmap` — previously only `formatUTCDate` and basic `calculateStreak` were tested.
- `onboarding.js` sits at 78.57% because `detectProgress` uses `Promise.all` with 4 different-table queries, requiring a per-table mock (like `createTableMock` in `streaks-advanced.test.js`). Candidate for next week.
- `email.js` tests exercise the non-exported `escapeHtml` and `generateReminderEmailHtml` functions indirectly through `sendReminderEmail`, verifying XSS protection.
- `cronAuth.js` line 36 (the `timingSafeEqual` branch) is uncovered because the timing-safe comparison only runs when token lengths match — covered by the "wrong token" test, but v8 coverage reports the individual line differently.
- `activity.js` remains at 77.58% — the `getGroupStats` leaderboard assembly and `getAllActivity` reactions assembly require per-call builder state not yet supported by the shared mock. Unchanged from previous report.
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.

10 changes: 8 additions & 2 deletions tests/setup/supabase-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ export function createMockSupabase() {
in: vi.fn(),
not: vi.fn(),
gte: vi.fn(),
lte: vi.fn(),
gt: vi.fn(),
lt: vi.fn(),
order: vi.fn(),
range: vi.fn(),
single: vi.fn(),
maybeSingle: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
or: vi.fn(),
limit: vi.fn(),
upsert: vi.fn(),

// Set what the final awaited value resolves to
mockReturnValue(value) {
Expand Down Expand Up @@ -56,9 +62,9 @@ export function createMockSupabase() {

// Every method returns the builder for chaining
const chainMethods = [
'select', 'eq', 'neq', 'in', 'not', 'gte',
'select', 'eq', 'neq', 'in', 'not', 'gte', 'lte', 'gt', 'lt',
'order', 'range', 'single', 'maybeSingle',
'insert', 'update', 'delete',
'insert', 'update', 'delete', 'or', 'limit', 'upsert',
];
chainMethods.forEach((method) => {
builder[method].mockReturnValue(builder);
Expand Down
188 changes: 188 additions & 0 deletions tests/unit/lib/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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({ error: null }),
NOTIFICATION_TYPES: { COMMENT_ON_ACTIVITY: 'comment_on_activity' },
}));

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

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

it('returns comments with user profiles attached', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{
data: [
{ id: 'c1', user_id: 'u1', comment_text: 'Great work!' },
{ id: 'c2', user_id: 'u2', comment_text: 'Keep it up!' },
],
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, 'activity-1');
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.error).toBeNull();
});

it('falls back to Unknown when profile is not found', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: [{ id: 'c1', user_id: 'u-missing', comment_text: 'Hello' }], error: null },
{ data: [], error: null },
]);

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

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

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

it('handles null profiles response gracefully', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: [{ id: 'c1', user_id: 'u1', comment_text: 'Hi' }], error: null },
{ data: null, error: null },
]);

const result = await getComments(supabase, 'activity-1');
expect(result.data[0].user.full_name).toBe('Unknown');
});
});

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

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

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

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 activities with zero comments', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValue({ data: [], error: null });

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

describe('postComment', () => {
it('returns success on happy path', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', activity_id: 'a1', comment_text: 'Nice!' }, error: null },
{ data: { user_id: 'other-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', '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);
expect(result.error).toBeTruthy();
});

it('does not notify when commenter is the activity author', async () => {
const { supabase, builder } = createMockSupabase();
builder.mockReturnValueSequence([
{ data: { id: 'c1', activity_id: 'a1', 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);
});
});

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

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

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

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

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

it('returns authorized 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 unauthorized when no authorization header', () => {
const request = makeRequest(null);
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
expect(result.response).toBeTruthy();
});

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 for wrong token', () => {
const request = makeRequest('Bearer wrong-secret');
const result = verifyCronSecret(request);
expect(result.authorized).toBe(false);
});

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

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

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