test: add unit test coverage for services and lib

- 255 tests across 14 files (was 70 tests in 4 files)
- src/services/__tests__: auth (registerUser, updateUserPassword, updateUserProfile), okrs (calculateOKRProgress, createOKR, updateKeyResult, updateOKR), teams (createTeam, addTeamMember, isAdminOfUser, getTeamMemberIdsForAdminTeams, getUserTeams), weather (getPreviousWeatherEntriesForUsers, shareWeatherSessionToTeam, getWeatherSessionsHistory), workshops (createSwotItem, duplicateSwotItem, updateAction, createMotivatorSession, updateCardInfluence, addGifMoodItem, shareGifMoodSessionToTeam, getLatestEventTimestamp, cleanupOldEvents)
- src/lib/__tests__: date-utils, weather-utils, okr-utils, gravatar, workshops, share-utils
- Update vitest coverage to include src/lib/**

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 08:37:32 +01:00
parent a8c05aa841
commit f9ed732f1c
15 changed files with 2907 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { getISOWeek, getWeekYearLabel, getWeekBounds } from '@/lib/date-utils';
// ── getISOWeek ─────────────────────────────────────────────────────────────
describe('getISOWeek', () => {
it('returns week 1 for January 4 (always in ISO week 1)', () => {
expect(getISOWeek(new Date(2026, 0, 4))).toBe(1);
expect(getISOWeek(new Date(2024, 0, 4))).toBe(1);
});
it('returns week 1 for Jan 1 when Jan 1 is Thursday (2026)', () => {
// Jan 1, 2026 is a Thursday → week 1
expect(getISOWeek(new Date(2026, 0, 1))).toBe(1);
});
it('returns week 53 for Dec 31 when it falls in last ISO week', () => {
// Dec 31, 2020 is a Thursday → week 53 of 2020
expect(getISOWeek(new Date(2020, 11, 31))).toBe(53);
});
it('returns correct week for a known mid-year date', () => {
// Mar 10, 2026 is in week 11
expect(getISOWeek(new Date(2026, 2, 10))).toBe(11);
});
it('returns week 52 for Dec 28 (always in ISO week 52 or 53)', () => {
// Dec 28 is always in the last week of the year per ISO
const week = getISOWeek(new Date(2026, 11, 28));
expect(week).toBeGreaterThanOrEqual(52);
});
it('week advances by 1 between consecutive Mondays', () => {
const w1 = getISOWeek(new Date(2026, 2, 9)); // Monday March 9
const w2 = getISOWeek(new Date(2026, 2, 16)); // Monday March 16
expect(w2 - w1).toBe(1);
});
it('same week for all days Monday through Saturday of a given week', () => {
// Week of March 915, 2026
const week = getISOWeek(new Date(2026, 2, 9)); // Monday
expect(getISOWeek(new Date(2026, 2, 10))).toBe(week); // Tuesday
expect(getISOWeek(new Date(2026, 2, 11))).toBe(week); // Wednesday
expect(getISOWeek(new Date(2026, 2, 14))).toBe(week); // Saturday
});
});
// ── getWeekYearLabel ──────────────────────────────────────────────────────
describe('getWeekYearLabel', () => {
it('formats as "S{NN}-{YYYY}"', () => {
const label = getWeekYearLabel(new Date(2026, 2, 10));
expect(label).toMatch(/^S\d{2}-\d{4}$/);
});
it('returns "S11-2026" for March 10 2026', () => {
expect(getWeekYearLabel(new Date(2026, 2, 10))).toBe('S11-2026');
});
it('zero-pads single-digit week numbers', () => {
// Jan 4, 2026 is week 1
expect(getWeekYearLabel(new Date(2026, 0, 4))).toBe('S01-2026');
});
});
// ── getWeekBounds ─────────────────────────────────────────────────────────
describe('getWeekBounds', () => {
it('returns Monday as start for a Wednesday', () => {
const { start } = getWeekBounds(new Date(2026, 2, 11)); // Wednesday March 11
expect(start.getDate()).toBe(9);
expect(start.getMonth()).toBe(2); // March
expect(start.getFullYear()).toBe(2026);
});
it('returns Sunday as end for a Wednesday', () => {
const { end } = getWeekBounds(new Date(2026, 2, 11));
expect(end.getDate()).toBe(15);
expect(end.getMonth()).toBe(2); // March
});
it('start is at 00:00:00.000', () => {
const { start } = getWeekBounds(new Date(2026, 2, 10));
expect(start.getHours()).toBe(0);
expect(start.getMinutes()).toBe(0);
expect(start.getSeconds()).toBe(0);
expect(start.getMilliseconds()).toBe(0);
});
it('end is at 23:59:59.999', () => {
const { end } = getWeekBounds(new Date(2026, 2, 10));
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
expect(end.getSeconds()).toBe(59);
expect(end.getMilliseconds()).toBe(999);
});
it('start and end are 6 days apart', () => {
const { start, end } = getWeekBounds(new Date(2026, 2, 10));
const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
expect(Math.floor(diffDays)).toBe(6);
});
it('returns same bounds for any day within the same week', () => {
const monday = getWeekBounds(new Date(2026, 2, 9));
const wednesday = getWeekBounds(new Date(2026, 2, 11));
const saturday = getWeekBounds(new Date(2026, 2, 14));
expect(monday.start.getTime()).toBe(wednesday.start.getTime());
expect(monday.start.getTime()).toBe(saturday.start.getTime());
expect(monday.end.getTime()).toBe(wednesday.end.getTime());
});
it('returns Monday as start when given a Monday', () => {
const { start } = getWeekBounds(new Date(2026, 2, 9)); // Monday March 9
expect(start.getDate()).toBe(9);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { getGravatarUrl } from '@/lib/gravatar';
import { createHash } from 'crypto';
// ── getGravatarUrl ─────────────────────────────────────────────────────────
describe('getGravatarUrl', () => {
it('generates a valid gravatar URL', () => {
const url = getGravatarUrl('test@example.com');
expect(url).toMatch(/^https:\/\/www\.gravatar\.com\/avatar\/[0-9a-f]{32}\?d=identicon&s=\d+$/);
});
it('uses MD5 hash of lowercased/trimmed email', () => {
const email = 'Test@Example.COM';
const hash = createHash('md5').update(email.toLowerCase().trim()).digest('hex');
const url = getGravatarUrl(email);
expect(url).toContain(`/avatar/${hash}`);
});
it('produces same URL regardless of email case', () => {
const lower = getGravatarUrl('user@example.com');
const upper = getGravatarUrl('USER@EXAMPLE.COM');
expect(lower).toBe(upper);
});
it('uses default size of 40', () => {
const url = getGravatarUrl('test@example.com');
expect(url).toContain('s=40');
});
it('uses custom size when provided', () => {
const url = getGravatarUrl('test@example.com', 80);
expect(url).toContain('s=80');
});
it('uses identicon as default fallback', () => {
const url = getGravatarUrl('test@example.com');
expect(url).toContain('d=identicon');
});
it('uses custom fallback when provided', () => {
const url = getGravatarUrl('test@example.com', 40, 'retro');
expect(url).toContain('d=retro');
});
it('produces different hashes for different emails', () => {
const url1 = getGravatarUrl('alice@example.com');
const url2 = getGravatarUrl('bob@example.com');
expect(url1).not.toBe(url2);
});
it('trims whitespace from email before hashing', () => {
const trimmed = getGravatarUrl('user@example.com');
const padded = getGravatarUrl(' user@example.com ');
expect(trimmed).toBe(padded);
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod, comparePeriods } from '@/lib/okr-utils';
// ── getCurrentQuarterPeriod ────────────────────────────────────────────────
describe('getCurrentQuarterPeriod', () => {
it('returns Q1 for January', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 0, 15))).toBe('Q1 2026');
});
it('returns Q1 for March', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 2, 31))).toBe('Q1 2026');
});
it('returns Q2 for April', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 3, 1))).toBe('Q2 2026');
});
it('returns Q2 for June', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 5, 30))).toBe('Q2 2026');
});
it('returns Q3 for July', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 6, 1))).toBe('Q3 2026');
});
it('returns Q4 for October', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 9, 1))).toBe('Q4 2026');
});
it('returns Q4 for December', () => {
expect(getCurrentQuarterPeriod(new Date(2026, 11, 31))).toBe('Q4 2026');
});
it('includes the correct year', () => {
expect(getCurrentQuarterPeriod(new Date(2025, 0, 1))).toBe('Q1 2025');
expect(getCurrentQuarterPeriod(new Date(2027, 6, 1))).toBe('Q3 2027');
});
});
// ── isCurrentQuarterPeriod ─────────────────────────────────────────────────
describe('isCurrentQuarterPeriod', () => {
it('returns true for matching period and date', () => {
expect(isCurrentQuarterPeriod('Q1 2026', new Date(2026, 1, 15))).toBe(true);
});
it('returns false for different quarter', () => {
expect(isCurrentQuarterPeriod('Q2 2026', new Date(2026, 0, 15))).toBe(false);
});
it('returns false for different year', () => {
expect(isCurrentQuarterPeriod('Q1 2025', new Date(2026, 0, 15))).toBe(false);
});
it('returns false for empty string', () => {
expect(isCurrentQuarterPeriod('', new Date(2026, 0, 15))).toBe(false);
});
});
// ── comparePeriods ─────────────────────────────────────────────────────────
describe('comparePeriods', () => {
it('returns 0 for identical periods', () => {
expect(comparePeriods('Q1 2026', 'Q1 2026')).toBe(0);
});
it('returns negative when a is more recent than b (a should sort first)', () => {
// Q2 2026 is more recent than Q1 2026
expect(comparePeriods('Q2 2026', 'Q1 2026')).toBeLessThan(0);
});
it('returns positive when a is older than b (a should sort after)', () => {
expect(comparePeriods('Q1 2026', 'Q2 2026')).toBeGreaterThan(0);
});
it('sorts by year before quarter', () => {
// Q4 2025 is older than Q1 2026
expect(comparePeriods('Q4 2025', 'Q1 2026')).toBeGreaterThan(0);
expect(comparePeriods('Q1 2026', 'Q4 2025')).toBeLessThan(0);
});
it('handles same year, different quarters correctly', () => {
expect(comparePeriods('Q4 2026', 'Q1 2026')).toBeLessThan(0);
expect(comparePeriods('Q1 2026', 'Q4 2026')).toBeGreaterThan(0);
});
it('puts parseable period before unparseable one', () => {
// 'Q1 2026' can be parsed, 'custom period' cannot
expect(comparePeriods('Q1 2026', 'custom period')).toBeLessThan(0);
expect(comparePeriods('custom period', 'Q1 2026')).toBeGreaterThan(0);
});
it('falls back to string comparison when neither is parseable', () => {
const result = comparePeriods('beta', 'alpha');
// String descending: 'beta' > 'alpha' → b.localeCompare(a) = 'alpha'.localeCompare('beta') < 0
expect(result).toBeLessThan(0);
});
it('produces correct sort order for a mixed list', () => {
const periods = ['Q1 2025', 'Q4 2026', 'Q2 2026', 'Q1 2026'];
const sorted = [...periods].sort(comparePeriods);
expect(sorted).toEqual(['Q4 2026', 'Q2 2026', 'Q1 2026', 'Q1 2025']);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { getTeamMembersForShare } from '@/lib/share-utils';
const alice = { id: 'u1', email: 'alice@ex.com', name: 'Alice' };
const bob = { id: 'u2', email: 'bob@ex.com', name: 'Bob' };
const charlie = { id: 'u3', email: 'charlie@ex.com', name: 'Charlie' };
// ── getTeamMembersForShare ─────────────────────────────────────────────────
describe('getTeamMembersForShare', () => {
it('returns empty array when teams have no members', () => {
const result = getTeamMembersForShare([{ id: 't1', name: 'Team', description: null, members: [] }], 'u1');
expect(result).toEqual([]);
});
it('returns empty array when all members are the current user', () => {
const teams = [{ id: 't1', name: 'Team', description: null, members: [{ user: alice }] }];
const result = getTeamMembersForShare(teams, alice.id);
expect(result).toEqual([]);
});
it('excludes the current user from results', () => {
const teams = [{ id: 't1', name: 'Team', description: null, members: [{ user: alice }, { user: bob }] }];
const result = getTeamMembersForShare(teams, alice.id);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(bob.id);
});
it('deduplicates users appearing in multiple teams', () => {
const teams = [
{ id: 't1', name: 'Team A', description: null, members: [{ user: bob }, { user: charlie }] },
{ id: 't2', name: 'Team B', description: null, members: [{ user: bob }] }, // bob repeated
];
const result = getTeamMembersForShare(teams, alice.id);
const ids = result.map((u) => u.id);
expect(ids).toContain(bob.id);
expect(ids).toContain(charlie.id);
expect(ids.filter((id) => id === bob.id)).toHaveLength(1); // no duplicate
});
it('returns all unique members across teams excluding current user', () => {
const teams = [
{ id: 't1', name: 'T1', description: null, members: [{ user: alice }, { user: bob }] },
{ id: 't2', name: 'T2', description: null, members: [{ user: charlie }] },
];
const result = getTeamMembersForShare(teams, alice.id);
const ids = result.map((u) => u.id);
expect(ids).not.toContain(alice.id);
expect(ids).toContain(bob.id);
expect(ids).toContain(charlie.id);
expect(result).toHaveLength(2);
});
it('handles teams without members property (undefined)', () => {
const teams = [{ id: 't1', name: 'Team', description: null }]; // no members key
const result = getTeamMembersForShare(teams, alice.id);
expect(result).toEqual([]);
});
it('returns empty array when no teams provided', () => {
expect(getTeamMembersForShare([], 'u1')).toEqual([]);
});
it('preserves user fields (id, email, name)', () => {
const teams = [{ id: 't1', name: 'T', description: null, members: [{ user: bob }] }];
const result = getTeamMembersForShare(teams, alice.id);
expect(result[0]).toEqual(bob);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { getEmojiScore, getAverageEmoji, getEmojiEvolution, WEATHER_EMOJIS } from '@/lib/weather-utils';
// ── getEmojiScore ─────────────────────────────────────────────────────────
describe('getEmojiScore', () => {
it('returns null for null', () => {
expect(getEmojiScore(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(getEmojiScore(undefined)).toBeNull();
});
it('returns null for empty string (Aucun = index 0)', () => {
expect(getEmojiScore('')).toBeNull();
});
it('returns null for unknown emoji', () => {
expect(getEmojiScore('🦄')).toBeNull();
});
it('returns 1 for ☀️ (first scored emoji)', () => {
expect(getEmojiScore('☀️')).toBe(1);
});
it('returns 19 for ✨ (last emoji in list)', () => {
expect(getEmojiScore('✨')).toBe(19);
});
it('returns consistent 1-based index for all emojis in WEATHER_EMOJIS', () => {
for (let i = 1; i < WEATHER_EMOJIS.length; i++) {
expect(getEmojiScore(WEATHER_EMOJIS[i].emoji)).toBe(i);
}
});
});
// ── getAverageEmoji ───────────────────────────────────────────────────────
describe('getAverageEmoji', () => {
it('returns null for empty array', () => {
expect(getAverageEmoji([])).toBeNull();
});
it('returns null when all values are null/undefined', () => {
expect(getAverageEmoji([null, undefined, null])).toBeNull();
});
it('returns null when all emojis are unknown (score null)', () => {
expect(getAverageEmoji(['🦄', '🤖'])).toBeNull();
});
it('returns the same emoji when only one is provided', () => {
expect(getAverageEmoji(['☀️'])).toBe('☀️');
});
it('ignores null values and uses only scored emojis', () => {
// Only ☀️ (1) is valid
expect(getAverageEmoji([null, '☀️', null])).toBe('☀️');
});
it('returns emoji closest to the average score', () => {
// ☀️ = 1, ☁️ = 4 → avg = 2.5 → closest is 🌤️(2) or ⛅(3), both at dist 0.5
// Code picks 🌤️ (lower index wins on tie)
const result = getAverageEmoji(['☀️', '☁️']);
expect(['🌤️', '⛅']).toContain(result);
});
it('returns exact match when average is a whole number score', () => {
// ☀️ = 1, 🌤️ = 2 → avg = 1.5 → dist(☀️)=0.5, dist(🌤️)=0.5 → ☀️ wins (lower index)
expect(getAverageEmoji(['☀️', '🌤️'])).toBe('☀️');
});
});
// ── getEmojiEvolution ─────────────────────────────────────────────────────
describe('getEmojiEvolution', () => {
it('returns null when current is null', () => {
expect(getEmojiEvolution(null, '☀️')).toBeNull();
});
it('returns null when previous is null', () => {
expect(getEmojiEvolution('☀️', null)).toBeNull();
});
it('returns null when both are null', () => {
expect(getEmojiEvolution(null, null)).toBeNull();
});
it('returns null when current is unknown emoji', () => {
expect(getEmojiEvolution('🦄', '☀️')).toBeNull();
});
it('returns null when previous is unknown emoji', () => {
expect(getEmojiEvolution('☀️', '🦄')).toBeNull();
});
it('returns "same" when both emojis are identical', () => {
expect(getEmojiEvolution('☀️', '☀️')).toBe('same');
});
it('returns "up" when current score is lower (better weather)', () => {
// ☀️ = 1 (better), 🌧️ = 6 (worse)
// going from 🌧️ to ☀️: delta = 1 - 6 = -5 → "up"
expect(getEmojiEvolution('☀️', '🌧️')).toBe('up');
});
it('returns "down" when current score is higher (worse weather)', () => {
// going from ☀️ to 🌧️: delta = 6 - 1 = 5 → "down"
expect(getEmojiEvolution('🌧️', '☀️')).toBe('down');
});
it('handles adjacent emojis', () => {
// ☀️(1) → 🌤️(2): delta = 2-1 = 1 → "down" (slight degradation)
expect(getEmojiEvolution('🌤️', '☀️')).toBe('down');
});
});

View File

@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import {
WORKSHOPS,
WORKSHOP_BY_ID,
WORKSHOP_TYPE_IDS,
VALID_TAB_PARAMS,
getWorkshop,
getSessionPath,
getSessionsTabUrl,
withWorkshopType,
} from '@/lib/workshops';
// ── Registry integrity ─────────────────────────────────────────────────────
describe('WORKSHOPS registry', () => {
it('contains exactly 6 workshop types', () => {
expect(WORKSHOPS).toHaveLength(6);
});
it('all workshop IDs match WORKSHOP_TYPE_IDS', () => {
const ids = WORKSHOPS.map((w) => w.id);
expect(ids).toEqual([...WORKSHOP_TYPE_IDS]);
});
it('every workshop has required fields', () => {
for (const w of WORKSHOPS) {
expect(w.id).toBeTruthy();
expect(w.path).toMatch(/^\//);
expect(w.newPath).toMatch(/^\//);
expect(w.label).toBeTruthy();
expect(w.icon).toBeTruthy();
}
});
it('workshops with participant have non-empty participantLabel', () => {
for (const w of WORKSHOPS) {
if (w.hasParticipant) {
expect(w.participantLabel).toBeTruthy();
} else {
expect(w.participantLabel).toBe('');
}
}
});
it('every workshop has home content with at least 1 feature', () => {
for (const w of WORKSHOPS) {
expect(w.home.tagline).toBeTruthy();
expect(w.home.description).toBeTruthy();
expect(w.home.features.length).toBeGreaterThanOrEqual(1);
}
});
});
describe('WORKSHOP_BY_ID', () => {
it('contains an entry for every workshop type', () => {
for (const id of WORKSHOP_TYPE_IDS) {
expect(WORKSHOP_BY_ID[id]).toBeDefined();
expect(WORKSHOP_BY_ID[id].id).toBe(id);
}
});
});
describe('VALID_TAB_PARAMS', () => {
it('includes all workshop IDs plus "all", "byPerson", "team"', () => {
expect(VALID_TAB_PARAMS).toContain('all');
expect(VALID_TAB_PARAMS).toContain('byPerson');
expect(VALID_TAB_PARAMS).toContain('team');
for (const id of WORKSHOP_TYPE_IDS) {
expect(VALID_TAB_PARAMS).toContain(id);
}
});
});
// ── getWorkshop ────────────────────────────────────────────────────────────
describe('getWorkshop', () => {
it('returns the correct workshop config', () => {
const swot = getWorkshop('swot');
expect(swot.id).toBe('swot');
expect(swot.path).toBe('/sessions');
});
it('returns different configs for different IDs', () => {
expect(getWorkshop('swot').path).not.toBe(getWorkshop('motivators').path);
});
});
// ── getSessionPath ─────────────────────────────────────────────────────────
describe('getSessionPath', () => {
it('builds path for swot', () => {
expect(getSessionPath('swot', 'abc123')).toBe('/sessions/abc123');
});
it('builds path for motivators', () => {
expect(getSessionPath('motivators', 'xyz')).toBe('/motivators/xyz');
});
it('builds path for weather', () => {
expect(getSessionPath('weather', 'w-1')).toBe('/weather/w-1');
});
});
// ── getSessionsTabUrl ──────────────────────────────────────────────────────
describe('getSessionsTabUrl', () => {
it('generates correct tab URL', () => {
expect(getSessionsTabUrl('swot')).toBe('/sessions?tab=swot');
expect(getSessionsTabUrl('motivators')).toBe('/sessions?tab=motivators');
expect(getSessionsTabUrl('weather')).toBe('/sessions?tab=weather');
});
});
// ── withWorkshopType ───────────────────────────────────────────────────────
describe('withWorkshopType', () => {
it('adds workshopType to each item', () => {
const sessions = [{ id: 'a' }, { id: 'b' }];
const result = withWorkshopType(sessions, 'swot');
expect(result[0]).toEqual({ id: 'a', workshopType: 'swot' });
expect(result[1]).toEqual({ id: 'b', workshopType: 'swot' });
});
it('returns empty array for empty input', () => {
expect(withWorkshopType([], 'motivators')).toEqual([]);
});
it('preserves all original properties', () => {
const sessions = [{ id: 'x', title: 'Test', createdAt: new Date() }];
const result = withWorkshopType(sessions, 'year-review');
expect(result[0].title).toBe('Test');
expect(result[0].workshopType).toBe('year-review');
});
it('does not mutate the original array', () => {
const sessions = [{ id: 'a' }];
withWorkshopType(sessions, 'swot');
expect(sessions[0]).not.toHaveProperty('workshopType');
});
});

View File

@@ -0,0 +1,290 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
batchResolveCollaborators,
resolveCollaborator,
registerUser,
updateUserPassword,
updateUserProfile,
} from '@/services/auth';
vi.mock('@/services/database', () => ({
prisma: {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock('bcryptjs', () => ({
hash: vi.fn().mockResolvedValue('hashed_password'),
compare: vi.fn(),
}));
// Import after mock to get the mocked version
const { prisma } = await import('@/services/database');
const mockFindMany = vi.mocked(prisma.user.findMany);
const mockFindUnique = vi.mocked(prisma.user.findUnique);
const mockFindFirst = vi.mocked(prisma.user.findFirst);
const mockCreate = vi.mocked(prisma.user.create);
const mockUpdate = vi.mocked(prisma.user.update);
const { hash: mockHash, compare: mockCompare } = await import('bcryptjs');
const mockHashFn = vi.mocked(mockHash);
const mockCompareFn = vi.mocked(mockCompare);
const alice = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
const bob = { id: 'u2', email: 'bob@example.com', name: 'Bob' };
// ── batchResolveCollaborators ──────────────────────────────────────────────
describe('batchResolveCollaborators', () => {
beforeEach(() => vi.clearAllMocks());
it('returns empty map for empty input without DB calls', async () => {
const result = await batchResolveCollaborators([]);
expect(result.size).toBe(0);
expect(mockFindMany).not.toHaveBeenCalled();
});
it('resolves an email to its user', async () => {
mockFindMany.mockResolvedValueOnce([alice]); // email query
const result = await batchResolveCollaborators(['alice@example.com']);
expect(result.get('alice@example.com')).toEqual({ raw: 'alice@example.com', matchedUser: alice });
});
it('email matching is case-insensitive', async () => {
mockFindMany.mockResolvedValueOnce([alice]); // DB returns lowercase email
const result = await batchResolveCollaborators(['Alice@Example.COM']);
expect(result.get('Alice@Example.COM')?.matchedUser).toEqual(alice);
});
it('resolves a name to its user', async () => {
mockFindMany.mockResolvedValueOnce([bob]); // name query (no emails → 1 call)
const result = await batchResolveCollaborators(['Bob']);
expect(result.get('Bob')).toEqual({ raw: 'Bob', matchedUser: bob });
});
it('name matching is case-insensitive', async () => {
mockFindMany.mockResolvedValueOnce([bob]);
const result = await batchResolveCollaborators(['BOB']);
expect(result.get('BOB')?.matchedUser).toEqual(bob);
});
it('returns null matchedUser when email not found', async () => {
mockFindMany.mockResolvedValueOnce([]); // no match
const result = await batchResolveCollaborators(['nobody@example.com']);
expect(result.get('nobody@example.com')).toEqual({ raw: 'nobody@example.com', matchedUser: null });
});
it('returns null matchedUser when name not found', async () => {
mockFindMany.mockResolvedValueOnce([]);
const result = await batchResolveCollaborators(['Unknown']);
expect(result.get('Unknown')).toEqual({ raw: 'Unknown', matchedUser: null });
});
it('handles mixed emails and names in exactly 2 DB queries', async () => {
mockFindMany
.mockResolvedValueOnce([alice]) // email query
.mockResolvedValueOnce([bob]); // name query
const result = await batchResolveCollaborators(['alice@example.com', 'Bob']);
expect(mockFindMany).toHaveBeenCalledTimes(2);
expect(result.get('alice@example.com')?.matchedUser).toEqual(alice);
expect(result.get('Bob')?.matchedUser).toEqual(bob);
});
it('deduplicates identical inputs (only 1 DB entry per unique value)', async () => {
mockFindMany.mockResolvedValueOnce([alice]); // only emails → 1 call
const result = await batchResolveCollaborators(['alice@example.com', 'alice@example.com']);
expect(mockFindMany).toHaveBeenCalledTimes(1);
expect(result.size).toBe(1);
});
it('skips email query when no emails present', async () => {
mockFindMany.mockResolvedValueOnce([bob]);
await batchResolveCollaborators(['Bob']);
expect(mockFindMany).toHaveBeenCalledTimes(1);
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ OR: expect.any(Array) }) })
);
});
it('skips name query when no names present', async () => {
mockFindMany.mockResolvedValueOnce([alice]);
await batchResolveCollaborators(['alice@example.com']);
expect(mockFindMany).toHaveBeenCalledTimes(1);
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ email: expect.any(Object) }) })
);
});
it('handles whitespace-padded inputs', async () => {
mockFindMany.mockResolvedValueOnce([alice]);
const result = await batchResolveCollaborators([' alice@example.com ']);
expect(result.get('alice@example.com')?.matchedUser).toEqual(alice);
});
it('resolves multiple unique names in one query', async () => {
const charlie = { id: 'u3', email: 'c@example.com', name: 'Charlie' };
mockFindMany.mockResolvedValueOnce([bob, charlie]);
const result = await batchResolveCollaborators(['Bob', 'Charlie']);
expect(mockFindMany).toHaveBeenCalledTimes(1);
expect(result.get('Bob')?.matchedUser).toEqual(bob);
expect(result.get('Charlie')?.matchedUser).toEqual(charlie);
});
});
// ── resolveCollaborator ────────────────────────────────────────────────────
describe('resolveCollaborator', () => {
beforeEach(() => vi.clearAllMocks());
it('resolves email via findUnique', async () => {
mockFindUnique.mockResolvedValueOnce(alice);
const result = await resolveCollaborator('alice@example.com');
expect(result).toEqual({ raw: 'alice@example.com', matchedUser: alice });
expect(mockFindUnique).toHaveBeenCalledWith({ where: { email: 'alice@example.com' }, select: expect.any(Object) });
});
it('returns null matchedUser when email not found', async () => {
mockFindUnique.mockResolvedValueOnce(null);
const result = await resolveCollaborator('ghost@example.com');
expect(result.matchedUser).toBeNull();
});
it('resolves name via findFirst when not an email', async () => {
mockFindFirst.mockResolvedValueOnce(bob);
const result = await resolveCollaborator('Bob');
expect(result.matchedUser).toEqual(bob);
});
it('returns null matchedUser when name not found', async () => {
mockFindFirst.mockResolvedValueOnce(null);
const result = await resolveCollaborator('Nobody');
expect(result.matchedUser).toBeNull();
});
it('rejects partial name matches (contains but not exact)', async () => {
// findFirst returns a user whose name doesn't match exactly
mockFindFirst.mockResolvedValueOnce({ id: 'u1', email: 'a@ex.com', name: 'Bobby' });
const result = await resolveCollaborator('Bob');
expect(result.matchedUser).toBeNull();
});
});
// ── registerUser ──────────────────────────────────────────────────────────
describe('registerUser', () => {
beforeEach(() => vi.clearAllMocks());
it('returns error when email already exists', async () => {
mockFindUnique.mockResolvedValueOnce(alice);
const result = await registerUser({ email: 'alice@example.com', password: 'secret' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/existe déjà/);
expect(mockCreate).not.toHaveBeenCalled();
});
it('hashes password and creates user on success', async () => {
mockFindUnique.mockResolvedValueOnce(null);
mockCreate.mockResolvedValueOnce({ id: 'new-id', email: 'new@example.com', name: null });
const result = await registerUser({ email: 'new@example.com', password: 'secret' });
expect(mockHashFn).toHaveBeenCalledWith('secret', 12);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ email: 'new@example.com', password: 'hashed_password' }) })
);
expect(result.success).toBe(true);
expect(result.user?.email).toBe('new@example.com');
});
it('returns only id, email, name fields', async () => {
mockFindUnique.mockResolvedValueOnce(null);
mockCreate.mockResolvedValueOnce({ id: 'x', email: 'x@ex.com', name: 'X' });
const result = await registerUser({ email: 'x@ex.com', password: 'p', name: 'X' });
expect(result.user).toEqual({ id: 'x', email: 'x@ex.com', name: 'X' });
});
it('sets name to null when not provided', async () => {
mockFindUnique.mockResolvedValueOnce(null);
mockCreate.mockResolvedValueOnce({ id: 'y', email: 'y@ex.com', name: null });
await registerUser({ email: 'y@ex.com', password: 'p' });
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ name: null }) })
);
});
});
// ── updateUserPassword ────────────────────────────────────────────────────
describe('updateUserPassword', () => {
beforeEach(() => vi.clearAllMocks());
it('returns error when user not found', async () => {
mockFindUnique.mockResolvedValueOnce(null);
const result = await updateUserPassword('u1', 'old', 'new');
expect(result.success).toBe(false);
expect(result.error).toMatch(/non trouvé/);
});
it('returns error when current password is incorrect', async () => {
mockFindUnique.mockResolvedValueOnce({ id: 'u1', password: 'hashed_old' });
mockCompareFn.mockResolvedValueOnce(false as never);
const result = await updateUserPassword('u1', 'wrong', 'new');
expect(result.success).toBe(false);
expect(result.error).toMatch(/incorrect/);
expect(mockUpdate).not.toHaveBeenCalled();
});
it('hashes new password and updates on success', async () => {
mockFindUnique.mockResolvedValueOnce({ id: 'u1', password: 'hashed_old' });
mockCompareFn.mockResolvedValueOnce(true as never);
mockUpdate.mockResolvedValueOnce({});
const result = await updateUserPassword('u1', 'old', 'newpass');
expect(mockHashFn).toHaveBeenCalledWith('newpass', 12);
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: { password: 'hashed_password' } })
);
expect(result.success).toBe(true);
});
});
// ── updateUserProfile ─────────────────────────────────────────────────────
describe('updateUserProfile', () => {
beforeEach(() => vi.clearAllMocks());
it('returns error when email is taken by another user', async () => {
mockFindFirst.mockResolvedValueOnce({ id: 'other', email: 'taken@ex.com' });
const result = await updateUserProfile('u1', { email: 'taken@ex.com' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/déjà utilisé/);
expect(mockUpdate).not.toHaveBeenCalled();
});
it('updates name and email on success', async () => {
mockFindFirst.mockResolvedValueOnce(null); // email not taken
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'new@ex.com', name: 'New Name' });
const result = await updateUserProfile('u1', { name: 'New Name', email: 'new@ex.com' });
expect(result.success).toBe(true);
expect(result.user).toEqual({ id: 'u1', email: 'new@ex.com', name: 'New Name' });
});
it('skips email uniqueness check when no email provided', async () => {
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'old@ex.com', name: 'Updated' });
const result = await updateUserProfile('u1', { name: 'Updated' });
expect(mockFindFirst).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});
it('passes correct query to check email uniqueness (excludes self)', async () => {
mockFindFirst.mockResolvedValueOnce(null);
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'new@ex.com', name: null });
await updateUserProfile('u1', { email: 'new@ex.com' });
expect(mockFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ NOT: { id: 'u1' } }) })
);
});
});

View File

@@ -0,0 +1,333 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createOKR, updateKeyResult, updateOKR, calculateOKRProgress } from '@/services/okrs';
vi.mock('@/services/database', () => {
const mockTx = {
oKR: {
create: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
keyResult: {
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
};
return {
prisma: {
oKR: {
findUnique: vi.fn(),
update: vi.fn(),
},
keyResult: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => unknown) => {
if (typeof fn === 'function') return fn(mockTx);
return Promise.all(fn as Promise<unknown>[]);
}),
_mockTx: mockTx,
},
};
});
const { prisma } = await import('@/services/database');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockTx = (prisma as any)._mockTx;
// ── calculateOKRProgress ──────────────────────────────────────────────────
describe('calculateOKRProgress', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 0 when OKR not found', async () => {
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce(null);
const result = await calculateOKRProgress('okr-1');
expect(result).toBe(0);
});
it('returns 0 for OKR with empty key results', async () => {
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({ id: 'okr-1', keyResults: [] } as never);
const result = await calculateOKRProgress('okr-1');
expect(result).toBe(0);
});
it('calculates average progress across key results', async () => {
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
id: 'okr-1',
keyResults: [
{ currentValue: 50, targetValue: 100 }, // 50%
{ currentValue: 100, targetValue: 100 }, // 100%
],
} as never);
const result = await calculateOKRProgress('okr-1');
expect(result).toBe(75);
});
it('caps individual KR progress at 100%', async () => {
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
id: 'okr-1',
keyResults: [
{ currentValue: 200, targetValue: 100 }, // over 100% → capped at 100
{ currentValue: 0, targetValue: 100 }, // 0%
],
} as never);
const result = await calculateOKRProgress('okr-1');
expect(result).toBe(50); // (100 + 0) / 2
});
it('returns 0 when targetValue is 0 (avoids division by zero)', async () => {
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
id: 'okr-1',
keyResults: [{ currentValue: 50, targetValue: 0 }],
} as never);
const result = await calculateOKRProgress('okr-1');
expect(result).toBe(0);
});
});
// ── createOKR ─────────────────────────────────────────────────────────────
describe('createOKR', () => {
beforeEach(() => vi.clearAllMocks());
it('creates OKR with NOT_STARTED status in transaction', async () => {
const createdOKR = { id: 'okr-1', teamMemberId: 'tm-1', objective: 'Grow revenue', status: 'NOT_STARTED' };
mockTx.oKR.create.mockResolvedValueOnce(createdOKR);
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
const result = await createOKR(
'tm-1',
'Grow revenue',
null,
'Q1-2026',
new Date(),
new Date(),
[{ title: 'KR1', targetValue: 100, unit: '%', order: 0 }]
);
expect(vi.mocked(prisma.$transaction)).toHaveBeenCalledTimes(1);
expect(mockTx.oKR.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'NOT_STARTED', teamMemberId: 'tm-1' }),
})
);
expect(result).toMatchObject({ id: 'okr-1' });
});
it('creates all key results with NOT_STARTED status', async () => {
const createdOKR = { id: 'okr-1' };
mockTx.oKR.create.mockResolvedValueOnce(createdOKR);
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1', status: 'NOT_STARTED' });
const keyResults = [
{ title: 'KR1', targetValue: 100, unit: '%', order: 0 },
{ title: 'KR2', targetValue: 50, unit: 'units', order: 1 },
];
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), keyResults);
expect(mockTx.keyResult.create).toHaveBeenCalledTimes(2);
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'NOT_STARTED', currentValue: 0 }) })
);
});
it('uses index as order when order is not provided', async () => {
mockTx.oKR.create.mockResolvedValueOnce({ id: 'okr-1' });
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
// Pass keyResults with explicit order
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), [
{ title: 'KR1', targetValue: 100, unit: '%', order: 5 },
]);
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ order: 5 }) })
);
});
it('defaults unit to % when unit is empty string', async () => {
mockTx.oKR.create.mockResolvedValueOnce({ id: 'okr-1' });
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), [
{ title: 'KR1', targetValue: 100, unit: '', order: 0 },
]);
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ unit: '%' }) })
);
});
});
// ── updateKeyResult ───────────────────────────────────────────────────────
describe('updateKeyResult', () => {
beforeEach(() => vi.clearAllMocks());
function makeKR(currentValue: number, targetValue: number, okrStatus = 'NOT_STARTED') {
return {
id: 'kr-1',
targetValue,
notes: null,
status: 'NOT_STARTED',
okr: { id: 'okr-1', status: okrStatus, keyResults: [{ currentValue, targetValue }] },
};
}
it('throws when key result not found', async () => {
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(null);
await expect(updateKeyResult('kr-1', 50, null)).rejects.toThrow('Key Result not found');
});
it('sets status to NOT_STARTED when progress is 0', async () => {
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(0, 100) as never);
await updateKeyResult('kr-1', 0, null);
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'NOT_STARTED' }) })
);
});
it('sets status to AT_RISK when progress is below 50%', async () => {
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(30, 100) as never);
await updateKeyResult('kr-1', 30, null);
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'AT_RISK' }) })
);
});
it('sets status to IN_PROGRESS when progress is 50% or more (but less than 100%)', async () => {
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(70, 100) as never);
await updateKeyResult('kr-1', 70, null);
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'IN_PROGRESS' }) })
);
});
it('sets status to COMPLETED when progress reaches 100%', async () => {
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(100, 100) as never);
await updateKeyResult('kr-1', 100, null);
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'COMPLETED' }) })
);
});
it('updates OKR status to IN_PROGRESS when progress > 0', async () => {
const kr = makeKR(0, 100, 'NOT_STARTED');
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
// update returns KR with okr having keyResults at 50%
const updatedKr = {
...kr,
okr: { id: 'okr-1', status: 'NOT_STARTED', keyResults: [{ currentValue: 50, targetValue: 100 }] },
};
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
await updateKeyResult('kr-1', 50, null);
expect(vi.mocked(prisma.oKR.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'IN_PROGRESS' } })
);
});
it('updates OKR status to COMPLETED when all KRs are 100%', async () => {
const kr = makeKR(0, 100, 'IN_PROGRESS');
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
const updatedKr = {
...kr,
okr: { id: 'okr-1', status: 'IN_PROGRESS', keyResults: [{ currentValue: 100, targetValue: 100 }] },
};
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
await updateKeyResult('kr-1', 100, null);
expect(vi.mocked(prisma.oKR.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'COMPLETED' } })
);
});
it('does not update OKR status if it has not changed', async () => {
const kr = makeKR(0, 100, 'IN_PROGRESS');
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
const updatedKr = {
...kr,
okr: { id: 'okr-1', status: 'IN_PROGRESS', keyResults: [{ currentValue: 60, targetValue: 100 }] },
};
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
await updateKeyResult('kr-1', 60, null);
expect(vi.mocked(prisma.oKR.update)).not.toHaveBeenCalled();
});
});
// ── updateOKR ─────────────────────────────────────────────────────────────
describe('updateOKR', () => {
beforeEach(() => vi.clearAllMocks());
it('updates OKR fields in transaction', async () => {
mockTx.oKR.update.mockResolvedValueOnce({});
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'IN_PROGRESS', keyResults: [] });
await updateOKR('okr-1', { objective: 'New objective', status: 'IN_PROGRESS' });
expect(mockTx.oKR.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'okr-1' },
data: expect.objectContaining({ objective: 'New objective', status: 'IN_PROGRESS' }),
})
);
});
it('deletes key results when delete list provided', async () => {
mockTx.oKR.update.mockResolvedValueOnce({});
mockTx.keyResult.deleteMany.mockResolvedValueOnce({ count: 1 });
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'NOT_STARTED', keyResults: [] });
await updateOKR('okr-1', {}, { delete: ['kr-1', 'kr-2'] });
expect(mockTx.keyResult.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['kr-1', 'kr-2'] }, okrId: 'okr-1' } })
);
});
it('creates new key results when create list provided', async () => {
mockTx.oKR.update.mockResolvedValueOnce({});
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-new' });
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'NOT_STARTED', keyResults: [] });
await updateOKR('okr-1', {}, {
create: [{ title: 'New KR', targetValue: 100, unit: '%', order: 0 }],
});
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ title: 'New KR', currentValue: 0, status: 'NOT_STARTED' }) })
);
});
it('throws when OKR not found after update', async () => {
mockTx.oKR.update.mockResolvedValueOnce({});
mockTx.oKR.findUnique.mockResolvedValueOnce(null);
await expect(updateOKR('okr-1', {})).rejects.toThrow('OKR not found after update');
});
it('includes progress in returned OKR', async () => {
mockTx.oKR.update.mockResolvedValueOnce({});
mockTx.oKR.findUnique.mockResolvedValueOnce({
id: 'okr-1',
status: 'IN_PROGRESS',
keyResults: [{ currentValue: 50, targetValue: 100 }],
});
const result = await updateOKR('okr-1', {});
expect(result).toMatchObject({ id: 'okr-1', progress: 50 });
});
});

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
createSessionPermissionChecks,
withAdminFallback,
canDeleteByOwner,
} from '@/services/session-permissions';
vi.mock('@/services/teams', () => ({
isAdminOfUser: vi.fn(),
}));
const { isAdminOfUser } = await import('@/services/teams');
const mockIsAdminOfUser = vi.mocked(isAdminOfUser);
// Factory for mock Prisma model delegate
function makeModel(countResult: number, ownerId: string | null = 'owner-1') {
return {
count: vi.fn().mockResolvedValue(countResult),
findUnique: vi.fn().mockResolvedValue(ownerId ? { userId: ownerId } : null),
};
}
describe('createSessionPermissionChecks', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAdminOfUser.mockResolvedValue(false);
});
describe('canAccess', () => {
it('returns true when user has direct access (count > 0)', async () => {
const { canAccess } = createSessionPermissionChecks(makeModel(1));
expect(await canAccess('session-1', 'user-1')).toBe(true);
});
it('returns true when no direct access but user is team admin', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const { canAccess } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
expect(await canAccess('session-1', 'admin-1')).toBe(true);
});
it('returns false when no direct access and not admin', async () => {
const { canAccess } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
expect(await canAccess('session-1', 'stranger')).toBe(false);
});
it('returns false when no direct access and session owner not found', async () => {
const { canAccess } = createSessionPermissionChecks(makeModel(0, null));
expect(await canAccess('session-1', 'anyone')).toBe(false);
});
});
describe('canEdit', () => {
it('returns true when user is owner or editor (count > 0)', async () => {
const { canEdit } = createSessionPermissionChecks(makeModel(1));
expect(await canEdit('session-1', 'editor-1')).toBe(true);
});
it('returns false for viewer (count = 0) when not admin', async () => {
const { canEdit } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
expect(await canEdit('session-1', 'viewer-1')).toBe(false);
});
it('returns true for viewer when user is team admin', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const { canEdit } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
expect(await canEdit('session-1', 'admin-1')).toBe(true);
});
});
describe('canDelete', () => {
it('returns true for session owner', async () => {
const { canDelete } = createSessionPermissionChecks(makeModel(1, 'owner-1'));
expect(await canDelete('session-1', 'owner-1')).toBe(true);
});
it('returns false for non-owner even with EDITOR role', async () => {
const { canDelete } = createSessionPermissionChecks(makeModel(1, 'owner-1'));
expect(await canDelete('session-1', 'editor-1')).toBe(false);
});
it('returns true when user is team admin of the owner', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const { canDelete } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
expect(await canDelete('session-1', 'admin-1')).toBe(true);
});
it('returns false when session not found', async () => {
const { canDelete } = createSessionPermissionChecks(makeModel(0, null));
expect(await canDelete('session-1', 'anyone')).toBe(false);
});
});
});
describe('withAdminFallback', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAdminOfUser.mockResolvedValue(false);
});
it('returns true immediately when hasDirectAccess is true (no admin check)', async () => {
const getOwnerId = vi.fn();
const result = await withAdminFallback(true, getOwnerId, 'session-1', 'user-1');
expect(result).toBe(true);
expect(getOwnerId).not.toHaveBeenCalled();
expect(mockIsAdminOfUser).not.toHaveBeenCalled();
});
it('falls back to admin check when no direct access', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'admin-1');
expect(result).toBe(true);
expect(mockIsAdminOfUser).toHaveBeenCalledWith('owner-1', 'admin-1');
});
it('returns false when no direct access and not admin', async () => {
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'stranger');
expect(result).toBe(false);
});
it('returns false when owner not found', async () => {
const getOwnerId = vi.fn().mockResolvedValue(null);
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'anyone');
expect(result).toBe(false);
expect(mockIsAdminOfUser).not.toHaveBeenCalled();
});
});
describe('canDeleteByOwner', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAdminOfUser.mockResolvedValue(false);
});
it('returns true when userId matches ownerId', async () => {
const getOwnerId = vi.fn().mockResolvedValue('user-1');
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'user-1')).toBe(true);
});
it('returns false when userId does not match and not admin', async () => {
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'other')).toBe(false);
});
it('returns true when user is admin of owner', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'admin-1')).toBe(true);
});
it('returns false when session not found (ownerId is null)', async () => {
const getOwnerId = vi.fn().mockResolvedValue(null);
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'anyone')).toBe(false);
});
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
mergeSessionsByUserId,
fetchTeamCollaboratorSessions,
getSessionByIdGeneric,
} from '@/services/session-queries';
vi.mock('@/services/teams', () => ({
isAdminOfUser: vi.fn().mockResolvedValue(false),
getTeamMemberIdsForAdminTeams: vi.fn().mockResolvedValue([]),
}));
const { isAdminOfUser } = await import('@/services/teams');
const mockIsAdminOfUser = vi.mocked(isAdminOfUser);
// ── Shared test data ───────────────────────────────────────────────────────
const USER_ID = 'user-1';
function makeSession(overrides: Partial<{
id: string; updatedAt: Date; userId: string;
shares: Array<{ userId: string; role?: string }>;
}> = {}) {
return {
id: 's1',
updatedAt: new Date('2024-06-01'),
userId: USER_ID,
user: { id: USER_ID, name: 'Alice', email: 'alice@example.com' },
shares: [],
...overrides,
};
}
// ── mergeSessionsByUserId ──────────────────────────────────────────────────
describe('mergeSessionsByUserId', () => {
beforeEach(() => vi.clearAllMocks());
it('marks owned sessions with isOwner=true and role=OWNER', async () => {
const s = makeSession();
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([s]),
vi.fn().mockResolvedValue([]),
USER_ID
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ id: 's1', isOwner: true, role: 'OWNER' });
});
it('marks shared sessions with isOwner=false and role from share', async () => {
const s = makeSession({ id: 's2', userId: 'other' });
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([]),
vi.fn().mockResolvedValue([{ session: s, role: 'VIEWER', createdAt: new Date() }]),
USER_ID
);
expect(result[0]).toMatchObject({ id: 's2', isOwner: false, role: 'VIEWER' });
});
it('sorts merged list by updatedAt descending', async () => {
const older = makeSession({ id: 's1', updatedAt: new Date('2024-01-01') });
const newer = makeSession({ id: 's2', updatedAt: new Date('2024-06-01') });
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([older, newer]),
vi.fn().mockResolvedValue([]),
USER_ID
);
expect(result[0].id).toBe('s2');
expect(result[1].id).toBe('s1');
});
it('merges owned and shared sessions together', async () => {
const owned = makeSession({ id: 'own' });
const shared = makeSession({ id: 'shared', userId: 'other', updatedAt: new Date('2020-01-01') });
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([owned]),
vi.fn().mockResolvedValue([{ session: shared, role: 'EDITOR', createdAt: new Date() }]),
USER_ID
);
expect(result).toHaveLength(2);
expect(result.map((s) => s.id)).toContain('own');
expect(result.map((s) => s.id)).toContain('shared');
});
it('returns empty array when no sessions', async () => {
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([]),
vi.fn().mockResolvedValue([]),
USER_ID
);
expect(result).toHaveLength(0);
});
it('applies resolveParticipant callback to each session', async () => {
const s = makeSession();
const resolveParticipant = vi.fn().mockResolvedValue({ extra: 'data' });
const result = await mergeSessionsByUserId(
vi.fn().mockResolvedValue([s]),
vi.fn().mockResolvedValue([]),
USER_ID,
resolveParticipant
);
expect(resolveParticipant).toHaveBeenCalledWith(expect.objectContaining({ id: 's1' }));
expect((result[0] as typeof result[0] & { extra: string }).extra).toBe('data');
});
});
// ── fetchTeamCollaboratorSessions ──────────────────────────────────────────
describe('fetchTeamCollaboratorSessions', () => {
beforeEach(() => vi.clearAllMocks());
it('returns empty array when team has no members', async () => {
const result = await fetchTeamCollaboratorSessions(
vi.fn(),
vi.fn().mockResolvedValue([]),
USER_ID
);
expect(result).toHaveLength(0);
});
it('marks sessions with isTeamCollab=true, canEdit=true, role=VIEWER', async () => {
const s = makeSession({ id: 'team-s', userId: 'member-1' });
const result = await fetchTeamCollaboratorSessions(
vi.fn().mockResolvedValue([s]),
vi.fn().mockResolvedValue(['member-1']),
USER_ID
);
expect(result[0]).toMatchObject({ id: 'team-s', isOwner: false, role: 'VIEWER', isTeamCollab: true, canEdit: true });
});
it('calls fetchTeamSessions with team member ids', async () => {
const fetchTeamSessions = vi.fn().mockResolvedValue([]);
await fetchTeamCollaboratorSessions(
fetchTeamSessions,
vi.fn().mockResolvedValue(['m1', 'm2']),
USER_ID
);
expect(fetchTeamSessions).toHaveBeenCalledWith(['m1', 'm2'], USER_ID);
});
it('applies resolveParticipant callback', async () => {
const s = makeSession({ id: 't-s', userId: 'member-1' });
const resolveParticipant = vi.fn().mockResolvedValue({ resolved: true });
const result = await fetchTeamCollaboratorSessions(
vi.fn().mockResolvedValue([s]),
vi.fn().mockResolvedValue(['member-1']),
USER_ID,
resolveParticipant
);
expect(resolveParticipant).toHaveBeenCalled();
expect((result[0] as typeof result[0] & { resolved: boolean }).resolved).toBe(true);
});
});
// ── getSessionByIdGeneric ──────────────────────────────────────────────────
describe('getSessionByIdGeneric', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAdminOfUser.mockResolvedValue(false);
});
it('returns session with isOwner=true when user is owner', async () => {
const s = makeSession({ userId: USER_ID, shares: [] });
const result = await getSessionByIdGeneric(
's1', USER_ID,
vi.fn().mockResolvedValue(s),
vi.fn()
);
expect(result).toMatchObject({ isOwner: true, role: 'OWNER', canEdit: true });
});
it('returns session with EDITOR role and canEdit=true for editor share', async () => {
const s = makeSession({ userId: 'owner-1', shares: [{ userId: USER_ID, role: 'EDITOR' }] });
const result = await getSessionByIdGeneric(
's1', USER_ID,
vi.fn().mockResolvedValue(s),
vi.fn()
);
expect(result).toMatchObject({ isOwner: false, role: 'EDITOR', canEdit: true });
});
it('returns session with VIEWER role and canEdit=false for viewer share', async () => {
const s = makeSession({ userId: 'owner-1', shares: [{ userId: USER_ID, role: 'VIEWER' }] });
const result = await getSessionByIdGeneric(
's1', USER_ID,
vi.fn().mockResolvedValue(s),
vi.fn()
);
expect(result).toMatchObject({ isOwner: false, role: 'VIEWER', canEdit: false });
});
it('returns null when session not found anywhere', async () => {
const result = await getSessionByIdGeneric(
'missing', USER_ID,
vi.fn().mockResolvedValue(null),
vi.fn().mockResolvedValue(null)
);
expect(result).toBeNull();
});
it('falls back to admin check when no direct access', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const s = makeSession({ userId: 'owner-1', shares: [] });
const result = await getSessionByIdGeneric(
's1', 'admin-1',
vi.fn().mockResolvedValue(null), // no direct access
vi.fn().mockResolvedValue(s) // but found by id
);
expect(result).not.toBeNull();
expect(mockIsAdminOfUser).toHaveBeenCalledWith('owner-1', 'admin-1');
});
it('returns null when no direct access and not admin', async () => {
const s = makeSession({ userId: 'owner-1', shares: [] });
const result = await getSessionByIdGeneric(
's1', 'stranger',
vi.fn().mockResolvedValue(null),
vi.fn().mockResolvedValue(s)
);
expect(result).toBeNull();
});
it('grants canEdit=true to team admin viewing member session', async () => {
mockIsAdminOfUser.mockResolvedValue(true);
const s = makeSession({ userId: 'owner-1', shares: [] });
const result = await getSessionByIdGeneric(
's1', 'admin-1',
vi.fn().mockResolvedValue(null),
vi.fn().mockResolvedValue(s)
);
expect(result?.canEdit).toBe(true);
});
it('applies resolveParticipant callback when provided', async () => {
const s = makeSession({ userId: USER_ID, shares: [] });
const resolveParticipant = vi.fn().mockResolvedValue({ participantName: 'Bob' });
const result = await getSessionByIdGeneric(
's1', USER_ID,
vi.fn().mockResolvedValue(s),
vi.fn(),
resolveParticipant
);
expect(resolveParticipant).toHaveBeenCalledWith(expect.objectContaining({ id: 's1' }));
expect((result as typeof result & { participantName: string })?.participantName).toBe('Bob');
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createShareAndEventHandlers } from '@/services/session-share-events';
vi.mock('@/services/database', () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const { prisma } = await import('@/services/database');
const mockFindUnique = vi.mocked(prisma.user.findUnique);
// ── Mock delegate factories ────────────────────────────────────────────────
const OWNER_ID = 'owner-1';
const SESSION_ID = 'session-1';
function makeSessionModel(session: object | null = { id: SESSION_ID, userId: OWNER_ID }) {
return { findFirst: vi.fn().mockResolvedValue(session) };
}
function makeShareModel() {
return {
upsert: vi.fn().mockResolvedValue({ id: 'share-1', userId: 'target-1', role: 'EDITOR' }),
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
findMany: vi.fn().mockResolvedValue([]),
};
}
function makeEventModel(eventResult = { id: 'e1', sessionId: SESSION_ID, userId: OWNER_ID, type: 'UPDATE', payload: '{}', createdAt: new Date() }) {
return {
create: vi.fn().mockResolvedValue(eventResult),
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
};
}
const canAccess = vi.fn().mockResolvedValue(true);
// ── share ──────────────────────────────────────────────────────────────────
describe('share', () => {
beforeEach(() => vi.clearAllMocks());
it('shares session with target user', async () => {
const targetUser = { id: 'target-1', email: 'bob@example.com', name: 'Bob' };
mockFindUnique.mockResolvedValue(targetUser);
const shareModel = makeShareModel();
const { share } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
await share(SESSION_ID, OWNER_ID, 'bob@example.com', 'EDITOR');
expect(shareModel.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { sessionId_userId: { sessionId: SESSION_ID, userId: 'target-1' } },
create: expect.objectContaining({ sessionId: SESSION_ID, userId: 'target-1', role: 'EDITOR' }),
})
);
});
it('throws when target user is not found', async () => {
mockFindUnique.mockResolvedValue(null);
const { share } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), canAccess);
await expect(share(SESSION_ID, OWNER_ID, 'ghost@example.com')).rejects.toThrow('User not found');
});
it('throws when trying to share with yourself', async () => {
mockFindUnique.mockResolvedValue({ id: OWNER_ID, email: 'owner@example.com', name: 'Owner' });
const { share } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), canAccess);
await expect(share(SESSION_ID, OWNER_ID, 'owner@example.com')).rejects.toThrow('Cannot share session with yourself');
});
it('throws when session not owned by caller', async () => {
mockFindUnique.mockResolvedValue({ id: 'target-1', email: 'bob@example.com', name: 'Bob' });
const sessionModel = makeSessionModel(null); // findFirst returns null → not owned
const { share } = createShareAndEventHandlers(sessionModel, makeShareModel(), makeEventModel(), canAccess);
await expect(share(SESSION_ID, 'not-owner', 'bob@example.com')).rejects.toThrow('Session not found or not owned');
});
it('defaults role to EDITOR', async () => {
const targetUser = { id: 'target-1', email: 'bob@example.com', name: 'Bob' };
mockFindUnique.mockResolvedValue(targetUser);
const shareModel = makeShareModel();
const { share } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
await share(SESSION_ID, OWNER_ID, 'bob@example.com');
expect(shareModel.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ role: 'EDITOR' }) })
);
});
});
// ── removeShare ────────────────────────────────────────────────────────────
describe('removeShare', () => {
beforeEach(() => vi.clearAllMocks());
it('removes share when caller is owner', async () => {
const shareModel = makeShareModel();
const { removeShare } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
await removeShare(SESSION_ID, OWNER_ID, 'target-1');
expect(shareModel.deleteMany).toHaveBeenCalledWith({ where: { sessionId: SESSION_ID, userId: 'target-1' } });
});
it('throws when session not owned by caller', async () => {
const { removeShare } = createShareAndEventHandlers(makeSessionModel(null), makeShareModel(), makeEventModel(), canAccess);
await expect(removeShare(SESSION_ID, 'not-owner', 'target-1')).rejects.toThrow('Session not found or not owned');
});
});
// ── createEvent ────────────────────────────────────────────────────────────
describe('createEvent', () => {
beforeEach(() => vi.clearAllMocks());
it('creates event with correct data', async () => {
const eventModel = makeEventModel();
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, { key: 'value' });
expect(eventModel.create).toHaveBeenCalledWith({
data: {
sessionId: SESSION_ID,
userId: OWNER_ID,
type: 'UPDATE',
payload: JSON.stringify({ key: 'value' }),
},
});
});
it('triggers fire-and-forget cleanup after event creation', async () => {
const eventModel = makeEventModel();
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {});
// deleteMany is called as fire-and-forget (not awaited, but should be invoked)
await vi.waitFor(() => expect(eventModel.deleteMany).toHaveBeenCalledTimes(1));
expect(eventModel.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { createdAt: expect.objectContaining({ lt: expect.any(Date) }) } })
);
});
it('does not throw when cleanup fails', async () => {
const eventModel = makeEventModel();
eventModel.deleteMany.mockRejectedValue(new Error('DB error'));
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
await expect(createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {})).resolves.not.toThrow();
});
it('returns the created event', async () => {
const created = { id: 'e42', sessionId: SESSION_ID, userId: OWNER_ID, type: 'UPDATE', payload: '{}', createdAt: new Date() };
const eventModel = makeEventModel(created);
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
const result = await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {});
expect(result.id).toBe('e42');
});
});
// ── getEvents ──────────────────────────────────────────────────────────────
describe('getEvents', () => {
beforeEach(() => vi.clearAllMocks());
it('fetches all events for a session without since filter', async () => {
const eventModel = makeEventModel();
const { getEvents } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
await getEvents(SESSION_ID);
expect(eventModel.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sessionId: SESSION_ID } })
);
});
it('filters events by since timestamp when provided', async () => {
const since = new Date('2024-01-01');
const eventModel = makeEventModel();
const { getEvents } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
await getEvents(SESSION_ID, since);
expect(eventModel.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sessionId: SESSION_ID, createdAt: { gt: since } } })
);
});
});
// ── getShares ──────────────────────────────────────────────────────────────
describe('getShares', () => {
beforeEach(() => vi.clearAllMocks());
it('returns shares when user has access', async () => {
const shares = [{ id: 'sh-1', user: { id: 'u1', name: 'Bob', email: 'bob@ex.com' } }];
const shareModel = makeShareModel();
shareModel.findMany.mockResolvedValue(shares);
const { getShares } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
const result = await getShares(SESSION_ID, OWNER_ID);
expect(result).toEqual(shares);
});
it('throws Access denied when user has no access', async () => {
const noAccess = vi.fn().mockResolvedValue(false);
const { getShares } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), noAccess);
await expect(getShares(SESSION_ID, 'stranger')).rejects.toThrow('Access denied');
});
});

View File

@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Make React cache a transparent pass-through in tests
vi.mock('react', () => ({
cache: (fn: unknown) => fn,
}));
vi.mock('@/services/database', () => {
const mockTx = {
team: { create: vi.fn() },
teamMember: { create: vi.fn() },
};
return {
prisma: {
team: { create: vi.fn(), update: vi.fn() },
teamMember: {
findUnique: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => unknown) => fn(mockTx)),
_mockTx: mockTx,
},
};
});
import { createTeam, addTeamMember, isAdminOfUser, getTeamMemberIdsForAdminTeams, getUserTeams } from '@/services/teams';
const { prisma } = await import('@/services/database');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockTx = (prisma as any)._mockTx;
// ── createTeam ────────────────────────────────────────────────────────────
describe('createTeam', () => {
beforeEach(() => vi.clearAllMocks());
it('creates team and adds creator as ADMIN in a transaction', async () => {
const team = { id: 'team-1', name: 'Alpha', description: null, createdById: 'user-1' };
mockTx.team.create.mockResolvedValueOnce(team);
mockTx.teamMember.create.mockResolvedValueOnce({});
const result = await createTeam('Alpha', null, 'user-1');
expect(vi.mocked(prisma.$transaction)).toHaveBeenCalledTimes(1);
expect(mockTx.team.create).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'Alpha', description: null, createdById: 'user-1' } })
);
expect(mockTx.teamMember.create).toHaveBeenCalledWith(
expect.objectContaining({ data: { teamId: 'team-1', userId: 'user-1', role: 'ADMIN' } })
);
expect(result).toEqual(team);
});
it('returns the created team (not the member)', async () => {
const team = { id: 'team-2', name: 'Beta', description: 'desc', createdById: 'user-2' };
mockTx.team.create.mockResolvedValueOnce(team);
mockTx.teamMember.create.mockResolvedValueOnce({ id: 'member-1' });
const result = await createTeam('Beta', 'desc', 'user-2');
expect(result).toEqual(team);
});
});
// ── addTeamMember ─────────────────────────────────────────────────────────
describe('addTeamMember', () => {
beforeEach(() => vi.clearAllMocks());
it('throws when user is already a member', async () => {
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce({
teamId: 'team-1', userId: 'user-1', role: 'MEMBER',
} as never);
await expect(addTeamMember('team-1', 'user-1')).rejects.toThrow('déjà membre');
expect(vi.mocked(prisma.teamMember.create)).not.toHaveBeenCalled();
});
it('creates member with MEMBER role by default', async () => {
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.teamMember.create).mockResolvedValueOnce({
userId: 'user-2', teamId: 'team-1', role: 'MEMBER',
} as never);
await addTeamMember('team-1', 'user-2');
expect(vi.mocked(prisma.teamMember.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: { teamId: 'team-1', userId: 'user-2', role: 'MEMBER' } })
);
});
it('creates member with specified role', async () => {
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.teamMember.create).mockResolvedValueOnce({} as never);
await addTeamMember('team-1', 'user-3', 'ADMIN');
expect(vi.mocked(prisma.teamMember.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ role: 'ADMIN' }) })
);
});
});
// ── getTeamMemberIdsForAdminTeams ──────────────────────────────────────────
describe('getTeamMemberIdsForAdminTeams', () => {
beforeEach(() => vi.clearAllMocks());
it('returns empty array when user is not admin of any team', async () => {
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
const result = await getTeamMemberIdsForAdminTeams('user-1');
expect(result).toEqual([]);
// Should not query members when no admin teams
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenCalledTimes(1);
});
it('returns deduplicated member IDs across admin teams', async () => {
// First call: get admin teams
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([{ teamId: 'team-1' }, { teamId: 'team-2' }] as never)
// Second call: get members of those teams
.mockResolvedValueOnce([{ userId: 'user-2' }, { userId: 'user-3' }] as never);
const result = await getTeamMemberIdsForAdminTeams('user-1');
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenCalledTimes(2);
expect(result).toEqual(['user-2', 'user-3']);
});
it('queries members excluding the admin user themselves', async () => {
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never)
.mockResolvedValueOnce([{ userId: 'user-2' }] as never);
await getTeamMemberIdsForAdminTeams('admin-user');
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ where: expect.objectContaining({ userId: { not: 'admin-user' } }) })
);
});
});
// ── isAdminOfUser ──────────────────────────────────────────────────────────
describe('isAdminOfUser', () => {
beforeEach(() => vi.clearAllMocks());
it('returns false when ownerUserId equals adminUserId', async () => {
const result = await isAdminOfUser('same-user', 'same-user');
expect(result).toBe(false);
expect(vi.mocked(prisma.teamMember.findMany)).not.toHaveBeenCalled();
});
it('returns true when adminUser is admin of a team containing ownerUser', async () => {
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never) // admin teams
.mockResolvedValueOnce([{ userId: 'owner-user' }] as never); // members
const result = await isAdminOfUser('owner-user', 'admin-user');
expect(result).toBe(true);
});
it('returns false when adminUser is not in same team as ownerUser', async () => {
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never)
.mockResolvedValueOnce([{ userId: 'other-user' }] as never); // different members
const result = await isAdminOfUser('owner-user', 'admin-user');
expect(result).toBe(false);
});
it('returns false when admin has no admin teams', async () => {
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
const result = await isAdminOfUser('owner-user', 'admin-user');
expect(result).toBe(false);
});
});
// ── getUserTeams ───────────────────────────────────────────────────────────
describe('getUserTeams', () => {
beforeEach(() => vi.clearAllMocks());
it('transforms result to include userRole and userOkrCount', async () => {
const mockMembership = {
role: 'ADMIN',
_count: { okrs: 3 },
team: {
id: 'team-1',
name: 'Alpha',
description: null,
members: [],
_count: { members: 5 },
},
};
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([mockMembership] as never);
const result = await getUserTeams('user-1');
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'team-1',
name: 'Alpha',
userRole: 'ADMIN',
userOkrCount: 3,
});
});
it('returns empty array when user is in no teams', async () => {
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
const result = await getUserTeams('user-1');
expect(result).toEqual([]);
});
it('spreads team properties into the result', async () => {
const mockMembership = {
role: 'MEMBER',
_count: { okrs: 0 },
team: {
id: 'team-2',
name: 'Beta',
description: 'A team',
members: [{ id: 'm-1' }],
_count: { members: 1 },
},
};
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([mockMembership] as never);
const result = await getUserTeams('user-2');
expect(result[0]).toMatchObject({
id: 'team-2',
description: 'A team',
userRole: 'MEMBER',
userOkrCount: 0,
});
});
});

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getPreviousWeatherEntriesForUsers,
shareWeatherSessionToTeam,
getWeatherSessionsHistory,
} from '@/services/weather';
vi.mock('@/services/database', () => ({
prisma: {
weatherEntry: {
findMany: vi.fn(),
},
weatherSession: {
findFirst: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
weatherSessionShare: {
findMany: vi.fn(),
upsert: vi.fn(),
},
teamMember: {
findMany: vi.fn(),
},
},
}));
// teams service is imported by weather.ts
vi.mock('@/services/teams', () => ({
getTeamMemberIdsForAdminTeams: vi.fn(),
}));
// session-permissions and session-share-events are module-level side effects
vi.mock('@/services/session-permissions', () => ({
createSessionPermissionChecks: () => ({
canAccess: vi.fn().mockResolvedValue(true),
canEdit: vi.fn().mockResolvedValue(true),
canDelete: vi.fn().mockResolvedValue(true),
}),
}));
vi.mock('@/services/session-share-events', () => ({
createShareAndEventHandlers: () => ({
share: vi.fn(),
removeShare: vi.fn(),
getShares: vi.fn(),
createEvent: vi.fn(),
getEvents: vi.fn(),
getLatestEventTimestamp: vi.fn(),
}),
}));
vi.mock('@/services/session-queries', () => ({
mergeSessionsByUserId: vi.fn(),
fetchTeamCollaboratorSessions: vi.fn(),
getSessionByIdGeneric: vi.fn(),
}));
const { prisma } = await import('@/services/database');
// ── getPreviousWeatherEntriesForUsers ──────────────────────────────────────
describe('getPreviousWeatherEntriesForUsers', () => {
beforeEach(() => vi.clearAllMocks());
it('returns empty map when userIds is empty', async () => {
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), []);
expect(result.size).toBe(0);
expect(vi.mocked(prisma.weatherEntry.findMany)).not.toHaveBeenCalled();
});
it('returns one entry per user using most recent values', async () => {
const olderDate = new Date('2026-01-01');
const newerDate = new Date('2026-01-08');
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
{ userId: 'u1', performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date: newerDate } },
{ userId: 'u1', performanceEmoji: '🌧️', moralEmoji: '☀️', fluxEmoji: null, valueCreationEmoji: null, session: { date: olderDate } },
] as never);
const result = await getPreviousWeatherEntriesForUsers('current-session', new Date(), ['u1']);
const entry = result.get('u1');
// Most recent entry (newerDate) wins for performanceEmoji
expect(entry?.performanceEmoji).toBe('☀️');
// Falls back to older entry for moralEmoji (newer was null)
expect(entry?.moralEmoji).toBe('☀️');
});
it('uses per-axis fallback when latest entry has null values', async () => {
const older = new Date('2026-01-01');
const newer = new Date('2026-01-08');
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
// Newer: has performance but not moral
{ userId: 'u1', performanceEmoji: '☁️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date: newer } },
// Older: has moral but not performance
{ userId: 'u1', performanceEmoji: null, moralEmoji: '🌤️', fluxEmoji: null, valueCreationEmoji: null, session: { date: older } },
] as never);
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1']);
const entry = result.get('u1');
expect(entry?.performanceEmoji).toBe('☁️'); // from newer
expect(entry?.moralEmoji).toBe('🌤️'); // fallback from older
});
it('handles multiple users independently', async () => {
const date = new Date('2026-01-01');
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
{ userId: 'u1', performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date } },
{ userId: 'u2', performanceEmoji: '🌧️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date } },
] as never);
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1', 'u2']);
expect(result.get('u1')?.performanceEmoji).toBe('☀️');
expect(result.get('u2')?.performanceEmoji).toBe('🌧️');
});
it('returns null for all axes when no previous entries for a user', async () => {
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([]);
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1']);
// No entries returned, map is empty
expect(result.get('u1')).toBeUndefined();
});
});
// ── shareWeatherSessionToTeam ──────────────────────────────────────────────
describe('shareWeatherSessionToTeam', () => {
beforeEach(() => vi.clearAllMocks());
it('throws when session not found or not owned', async () => {
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce(null);
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
'Session not found or not owned'
);
});
it('throws when another weather session exists for same team this week', async () => {
const sessionDate = new Date('2026-03-10');
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([{ userId: 'u2' }, { userId: 'u3' }] as never); // count check
vi.mocked(prisma.weatherSession.count).mockResolvedValueOnce(1); // existing session this week
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
'déjà une météo pour cette semaine'
);
});
it('throws when team has no members', async () => {
const sessionDate = new Date('2026-03-10');
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([]) // no members for count check → skip week check
.mockResolvedValueOnce([]); // no full members either
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
'Team has no members'
);
});
it('shares session with all team members except owner', async () => {
const sessionDate = new Date('2026-03-10');
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
// First findMany: team member IDs for week check (empty → skips count check)
vi.mocked(prisma.teamMember.findMany)
.mockResolvedValueOnce([]) // empty for week-check path
.mockResolvedValueOnce([
{ userId: 'owner-1', user: { id: 'owner-1', name: 'Owner', email: 'o@ex.com' } },
{ userId: 'member-1', user: { id: 'member-1', name: 'Member', email: 'm@ex.com' } },
{ userId: 'member-2', user: { id: 'member-2', name: 'Member2', email: 'm2@ex.com' } },
] as never); // full members
vi.mocked(prisma.weatherSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
await shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1');
// Should call upsert for member-1 and member-2 (not owner-1)
expect(vi.mocked(prisma.weatherSessionShare.upsert)).toHaveBeenCalledTimes(2);
const calls = vi.mocked(prisma.weatherSessionShare.upsert).mock.calls;
const sharedUserIds = calls.map((c) => c[0].create.userId);
expect(sharedUserIds).toContain('member-1');
expect(sharedUserIds).toContain('member-2');
expect(sharedUserIds).not.toContain('owner-1');
});
});
// ── getWeatherSessionsHistory ──────────────────────────────────────────────
describe('getWeatherSessionsHistory', () => {
beforeEach(() => vi.clearAllMocks());
it('returns sorted history by date ascending', async () => {
const older = new Date('2026-01-01');
const newer = new Date('2026-02-01');
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
{ id: 's-2', title: 'Session 2', date: newer, entries: [] },
{ id: 's-1', title: 'Session 1', date: older, entries: [] },
] as never);
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
const result = await getWeatherSessionsHistory('user-1');
expect(result[0].sessionId).toBe('s-1');
expect(result[1].sessionId).toBe('s-2');
});
it('deduplicates sessions appearing in both own and shared', async () => {
const date = new Date('2026-01-01');
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
{ id: 's-1', title: 'Session', date, entries: [] },
] as never);
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([
{ session: { id: 's-1', title: 'Session', date, entries: [] } },
] as never);
const result = await getWeatherSessionsHistory('user-1');
expect(result).toHaveLength(1);
});
it('calculates average scores from entries using emoji → number mapping', async () => {
const date = new Date('2026-01-01');
// '☀️' is index 1 in WEATHER_EMOJIS, '🌤️' is index 2
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
{
id: 's-1',
title: 'Session',
date,
entries: [
{ performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null },
{ performanceEmoji: '🌤️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null },
],
},
] as never);
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
const result = await getWeatherSessionsHistory('user-1');
// avgScore(['☀️', '🌤️']) = (1 + 2) / 2 = 1.5
expect(result[0].performance).toBe(1.5);
expect(result[0].moral).toBeNull(); // all null → null
});
it('returns null score when no entries have that axis', async () => {
const date = new Date('2026-01-01');
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
{ id: 's-1', title: 'Session', date, entries: [] },
] as never);
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
const result = await getWeatherSessionsHistory('user-1');
expect(result[0].performance).toBeNull();
expect(result[0].moral).toBeNull();
expect(result[0].flux).toBeNull();
expect(result[0].valueCreation).toBeNull();
});
it('includes both own and shared sessions', async () => {
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
{ id: 's-own', title: 'Own', date: new Date('2026-01-01'), entries: [] },
] as never);
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([
{ session: { id: 's-shared', title: 'Shared', date: new Date('2026-02-01'), entries: [] } },
] as never);
const result = await getWeatherSessionsHistory('user-1');
expect(result).toHaveLength(2);
const ids = result.map((r) => r.sessionId);
expect(ids).toContain('s-own');
expect(ids).toContain('s-shared');
});
});

View File

@@ -0,0 +1,511 @@
/**
* Workshop-specific business logic tests:
* - sessions.ts: createSwotItem, duplicateSwotItem, updateAction
* - moving-motivators.ts: createMotivatorSession, updateCardInfluence
* - gif-mood.ts: addGifMoodItem, shareGifMoodSessionToTeam
* - session-share-events.ts: getLatestEventTimestamp, cleanupOldEvents
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Shared mocks ────────────────────────────────────────────────────────────
vi.mock('@/services/database', () => ({
prisma: {
swotItem: {
aggregate: vi.fn(),
create: vi.fn(),
findUnique: vi.fn(),
},
actionLink: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
action: {
update: vi.fn(),
},
movingMotivatorsSession: {
create: vi.fn(),
},
motivatorCard: {
update: vi.fn(),
},
gifMoodItem: {
count: vi.fn(),
create: vi.fn(),
},
gifMoodSession: {
findFirst: vi.fn(),
},
gMSessionShare: {
upsert: vi.fn(),
},
teamMember: {
findMany: vi.fn(),
},
},
}));
vi.mock('@/services/auth', () => ({
resolveCollaborator: vi.fn(),
batchResolveCollaborators: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock('@/services/teams', () => ({
getTeamMemberIdsForAdminTeams: vi.fn(),
}));
vi.mock('@/services/session-permissions', () => ({
createSessionPermissionChecks: () => ({
canAccess: vi.fn().mockResolvedValue(true),
canEdit: vi.fn().mockResolvedValue(true),
canDelete: vi.fn().mockResolvedValue(true),
}),
}));
vi.mock('@/services/session-share-events', () => ({
createShareAndEventHandlers: () => ({
share: vi.fn(),
removeShare: vi.fn(),
getShares: vi.fn(),
createEvent: vi.fn(),
getEvents: vi.fn(),
getLatestEventTimestamp: vi.fn(),
}),
}));
vi.mock('@/services/session-queries', () => ({
mergeSessionsByUserId: vi.fn().mockResolvedValue([]),
fetchTeamCollaboratorSessions: vi.fn().mockResolvedValue([]),
getSessionByIdGeneric: vi.fn(),
}));
const { prisma } = await import('@/services/database');
// ── sessions.ts: createSwotItem ──────────────────────────────────────────
import { createSwotItem, duplicateSwotItem, updateAction } from '@/services/sessions';
describe('createSwotItem', () => {
beforeEach(() => vi.clearAllMocks());
it('uses maxOrder + 1 for the new item order', async () => {
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: 3 } } as never);
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-1', order: 4 } as never);
await createSwotItem('session-1', { content: 'New item', category: 'STRENGTH' });
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ order: 4 }) })
);
});
it('uses order 0 when no items exist yet', async () => {
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-1', order: 0 } as never);
await createSwotItem('session-1', { content: 'First item', category: 'WEAKNESS' });
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ order: 0 }) })
);
});
it('queries aggregate for the correct session and category', async () => {
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({} as never);
await createSwotItem('session-42', { content: 'Item', category: 'OPPORTUNITY' });
expect(vi.mocked(prisma.swotItem.aggregate)).toHaveBeenCalledWith(
expect.objectContaining({ where: { sessionId: 'session-42', category: 'OPPORTUNITY' } })
);
});
});
// ── sessions.ts: duplicateSwotItem ────────────────────────────────────────
describe('duplicateSwotItem', () => {
beforeEach(() => vi.clearAllMocks());
it('throws when original item not found', async () => {
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(null);
await expect(duplicateSwotItem('item-999')).rejects.toThrow('Item not found');
});
it('creates copy with recalculated order (maxOrder + 1)', async () => {
const original = { id: 'item-1', content: 'Original', category: 'STRENGTH', sessionId: 'session-1' };
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(original as never);
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: 5 } } as never);
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-2', order: 6 } as never);
await duplicateSwotItem('item-1');
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
expect.objectContaining({
data: {
content: 'Original',
category: 'STRENGTH',
sessionId: 'session-1',
order: 6,
},
})
);
});
it('uses order 0 when category has no existing items', async () => {
const original = { id: 'item-1', content: 'Item', category: 'THREAT', sessionId: 's-1' };
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(original as never);
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-2', order: 0 } as never);
await duplicateSwotItem('item-1');
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ order: 0 }) })
);
});
});
// ── sessions.ts: updateAction ─────────────────────────────────────────────
describe('updateAction', () => {
beforeEach(() => vi.clearAllMocks());
it('does not touch links when linkedItemIds is not provided', async () => {
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
await updateAction('a-1', { title: 'Updated title' });
expect(vi.mocked(prisma.actionLink.deleteMany)).not.toHaveBeenCalled();
expect(vi.mocked(prisma.actionLink.createMany)).not.toHaveBeenCalled();
});
it('deletes all existing links and recreates when linkedItemIds is provided', async () => {
vi.mocked(prisma.actionLink.deleteMany).mockResolvedValueOnce({ count: 2 } as never);
vi.mocked(prisma.actionLink.createMany).mockResolvedValueOnce({ count: 2 } as never);
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
await updateAction('a-1', { linkedItemIds: ['item-1', 'item-2'] });
expect(vi.mocked(prisma.actionLink.deleteMany)).toHaveBeenCalledWith(
expect.objectContaining({ where: { actionId: 'a-1' } })
);
expect(vi.mocked(prisma.actionLink.createMany)).toHaveBeenCalledWith({
data: [
{ actionId: 'a-1', swotItemId: 'item-1' },
{ actionId: 'a-1', swotItemId: 'item-2' },
],
});
});
it('deletes links but skips createMany when linkedItemIds is empty', async () => {
vi.mocked(prisma.actionLink.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
await updateAction('a-1', { linkedItemIds: [] });
expect(vi.mocked(prisma.actionLink.deleteMany)).toHaveBeenCalled();
expect(vi.mocked(prisma.actionLink.createMany)).not.toHaveBeenCalled();
});
});
// ── moving-motivators.ts: createMotivatorSession ──────────────────────────
import { createMotivatorSession, updateCardInfluence } from '@/services/moving-motivators';
describe('createMotivatorSession', () => {
beforeEach(() => vi.clearAllMocks());
it('creates session with exactly 10 motivator cards', async () => {
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({
id: 'session-1',
cards: new Array(10).fill({ id: 'c', type: 'STATUS', orderIndex: 1, influence: 0 }),
} as never);
await createMotivatorSession('user-1', { title: 'Test', participant: 'Alice' });
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
expect(call.data.cards.create).toHaveLength(10);
});
it('initializes all cards with influence 0 and correct orderIndex (1-based)', async () => {
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({ id: 's-1', cards: [] } as never);
await createMotivatorSession('user-1', { title: 'Test', participant: 'Alice' });
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
const cards = call.data.cards.create;
expect(cards[0]).toMatchObject({ influence: 0, orderIndex: 1 });
expect(cards[9]).toMatchObject({ influence: 0, orderIndex: 10 });
});
it('includes all 10 motivator types', async () => {
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({ id: 's-1', cards: [] } as never);
await createMotivatorSession('user-1', { title: 'T', participant: 'B' });
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
const types = call.data.cards.create.map((c: { type: string }) => c.type);
expect(types).toContain('STATUS');
expect(types).toContain('PURPOSE');
expect(types).toContain('CURIOSITY');
expect(types).toContain('FREEDOM');
expect(new Set(types).size).toBe(10);
});
});
// ── moving-motivators.ts: updateCardInfluence ─────────────────────────────
describe('updateCardInfluence', () => {
beforeEach(() => vi.clearAllMocks());
it('passes influence value as-is when within bounds', async () => {
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: 2 } as never);
await updateCardInfluence('c-1', 2);
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: { influence: 2 } })
);
});
it('clamps influence to -3 when below minimum', async () => {
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: -3 } as never);
await updateCardInfluence('c-1', -10);
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: { influence: -3 } })
);
});
it('clamps influence to +3 when above maximum', async () => {
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: 3 } as never);
await updateCardInfluence('c-1', 99);
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
expect.objectContaining({ data: { influence: 3 } })
);
});
it('allows exact boundary values -3 and +3', async () => {
vi.mocked(prisma.motivatorCard.update).mockResolvedValue({ id: 'c-1', influence: -3 } as never);
await updateCardInfluence('c-1', -3);
await updateCardInfluence('c-1', 3);
const calls = vi.mocked(prisma.motivatorCard.update).mock.calls;
expect(calls[0][0].data.influence).toBe(-3);
expect(calls[1][0].data.influence).toBe(3);
});
});
// ── gif-mood.ts: addGifMoodItem ────────────────────────────────────────────
import { addGifMoodItem, shareGifMoodSessionToTeam } from '@/services/gif-mood';
describe('addGifMoodItem', () => {
beforeEach(() => vi.clearAllMocks());
it('throws when user has reached GIF_MOOD_MAX_ITEMS limit (5)', async () => {
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(5);
await expect(addGifMoodItem('session-1', 'user-1', { gifUrl: 'https://example.com/gif.gif' }))
.rejects.toThrow('Maximum 5');
});
it('creates item with order set to current count (append at end)', async () => {
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(2);
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1', order: 2 } as never);
await addGifMoodItem('session-1', 'user-1', { gifUrl: 'https://example.com/gif.gif' });
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ order: 2 }) })
);
});
it('allows adding when count is below limit', async () => {
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(4);
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1' } as never);
await expect(addGifMoodItem('s-1', 'u-1', { gifUrl: 'url', note: 'hello' })).resolves.not.toThrow();
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ note: 'hello' }) })
);
});
it('sets note to null when not provided', async () => {
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(0);
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1' } as never);
await addGifMoodItem('s-1', 'u-1', { gifUrl: 'url' });
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ note: null }) })
);
});
});
// ── gif-mood.ts: shareGifMoodSessionToTeam ────────────────────────────────
describe('shareGifMoodSessionToTeam', () => {
beforeEach(() => vi.clearAllMocks());
it('throws when session not found or not owned', async () => {
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce(null);
await expect(shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
'Session not found or not owned'
);
});
it('throws when team has no members', async () => {
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
await expect(shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
'Team has no members'
);
});
it('shares with all team members except owner', async () => {
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([
{ userId: 'owner-1', user: { id: 'owner-1', name: 'Owner', email: 'o@ex.com' } },
{ userId: 'member-1', user: { id: 'member-1', name: 'Member', email: 'm@ex.com' } },
] as never);
vi.mocked(prisma.gMSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
await shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1');
// Only member-1 gets shared (not owner-1)
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledTimes(1);
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ userId: 'member-1' }) })
);
});
it('uses EDITOR role by default', async () => {
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([
{ userId: 'member-1', user: { id: 'member-1', name: 'M', email: 'm@ex.com' } },
] as never);
vi.mocked(prisma.gMSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
await shareGifMoodSessionToTeam('s-1', 'different-owner', 'team-1');
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ role: 'EDITOR' }) })
);
});
});
// ── session-share-events.ts: getLatestEventTimestamp & cleanupOldEvents ──────
// Use the real implementation (not the mock above which is for internal service use)
const { createShareAndEventHandlers } = await vi.importActual<
typeof import('@/services/session-share-events')
>('@/services/session-share-events');
describe('getLatestEventTimestamp', () => {
it('returns the createdAt of the most recent event', async () => {
const ts = new Date('2026-03-10T10:00:00Z');
const eventModel = {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValueOnce({ createdAt: ts }),
deleteMany: vi.fn(),
};
const { getLatestEventTimestamp } = createShareAndEventHandlers(
{ findFirst: vi.fn() },
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
eventModel,
vi.fn()
);
const result = await getLatestEventTimestamp('session-1');
expect(result).toEqual(ts);
});
it('returns undefined when no events exist', async () => {
const eventModel = {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValueOnce(null),
deleteMany: vi.fn(),
};
const { getLatestEventTimestamp } = createShareAndEventHandlers(
{ findFirst: vi.fn() },
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
eventModel,
vi.fn()
);
const result = await getLatestEventTimestamp('session-1');
expect(result).toBeUndefined();
});
});
describe('cleanupOldEvents', () => {
it('deletes events older than 24 hours by default', async () => {
const deleteMany = vi.fn().mockResolvedValueOnce({ count: 5 });
const eventModel = { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), deleteMany };
const { cleanupOldEvents } = createShareAndEventHandlers(
{ findFirst: vi.fn() },
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
eventModel,
vi.fn()
);
const before = Date.now();
await cleanupOldEvents();
const after = Date.now();
const cutoff: Date = deleteMany.mock.calls[0][0].where.createdAt.lt;
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - 24 * 60 * 60 * 1000 - 100);
expect(cutoff.getTime()).toBeLessThanOrEqual(after - 24 * 60 * 60 * 1000 + 100);
});
it('deletes events older than custom maxAgeHours', async () => {
const deleteMany = vi.fn().mockResolvedValueOnce({ count: 0 });
const eventModel = { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), deleteMany };
const { cleanupOldEvents } = createShareAndEventHandlers(
{ findFirst: vi.fn() },
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
eventModel,
vi.fn()
);
const before = Date.now();
await cleanupOldEvents(1); // 1 hour
const after = Date.now();
const cutoff: Date = deleteMany.mock.calls[0][0].where.createdAt.lt;
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - 60 * 60 * 1000 - 100);
expect(cutoff.getTime()).toBeLessThanOrEqual(after - 60 * 60 * 1000 + 100);
});
it('returns the count of deleted events', async () => {
const eventModel = {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
deleteMany: vi.fn().mockResolvedValueOnce({ count: 42 }),
};
const { cleanupOldEvents } = createShareAndEventHandlers(
{ findFirst: vi.fn() },
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
eventModel,
vi.fn()
);
const result = await cleanupOldEvents();
expect(result).toEqual({ count: 42 });
});
});

23
vitest.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: true,
coverage: {
provider: 'v8',
include: ['src/services/**/*.ts', 'src/lib/**/*.ts'],
exclude: [
'src/services/__tests__/**',
'src/lib/__tests__/**',
'src/services/database.ts',
'src/lib/types.ts',
'src/lib/auth.ts',
'src/lib/auth.config.ts',
],
reporter: ['text', 'html'],
},
},
});