diff --git a/src/lib/__tests__/date-utils.test.ts b/src/lib/__tests__/date-utils.test.ts new file mode 100644 index 0000000..fed527d --- /dev/null +++ b/src/lib/__tests__/date-utils.test.ts @@ -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 9–15, 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); + }); +}); diff --git a/src/lib/__tests__/gravatar.test.ts b/src/lib/__tests__/gravatar.test.ts new file mode 100644 index 0000000..b23d124 --- /dev/null +++ b/src/lib/__tests__/gravatar.test.ts @@ -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); + }); +}); diff --git a/src/lib/__tests__/okr-utils.test.ts b/src/lib/__tests__/okr-utils.test.ts new file mode 100644 index 0000000..c788994 --- /dev/null +++ b/src/lib/__tests__/okr-utils.test.ts @@ -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']); + }); +}); diff --git a/src/lib/__tests__/share-utils.test.ts b/src/lib/__tests__/share-utils.test.ts new file mode 100644 index 0000000..5230266 --- /dev/null +++ b/src/lib/__tests__/share-utils.test.ts @@ -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); + }); +}); diff --git a/src/lib/__tests__/weather-utils.test.ts b/src/lib/__tests__/weather-utils.test.ts new file mode 100644 index 0000000..7a69749 --- /dev/null +++ b/src/lib/__tests__/weather-utils.test.ts @@ -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'); + }); +}); diff --git a/src/lib/__tests__/workshops.test.ts b/src/lib/__tests__/workshops.test.ts new file mode 100644 index 0000000..d7f122e --- /dev/null +++ b/src/lib/__tests__/workshops.test.ts @@ -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'); + }); +}); diff --git a/src/services/__tests__/auth.test.ts b/src/services/__tests__/auth.test.ts new file mode 100644 index 0000000..285c5de --- /dev/null +++ b/src/services/__tests__/auth.test.ts @@ -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' } }) }) + ); + }); +}); diff --git a/src/services/__tests__/okrs.test.ts b/src/services/__tests__/okrs.test.ts new file mode 100644 index 0000000..dc3db33 --- /dev/null +++ b/src/services/__tests__/okrs.test.ts @@ -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[]); + }), + _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 }); + }); +}); diff --git a/src/services/__tests__/session-permissions.test.ts b/src/services/__tests__/session-permissions.test.ts new file mode 100644 index 0000000..4e8f80a --- /dev/null +++ b/src/services/__tests__/session-permissions.test.ts @@ -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); + }); +}); diff --git a/src/services/__tests__/session-queries.test.ts b/src/services/__tests__/session-queries.test.ts new file mode 100644 index 0000000..410f843 --- /dev/null +++ b/src/services/__tests__/session-queries.test.ts @@ -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'); + }); +}); diff --git a/src/services/__tests__/session-share-events.test.ts b/src/services/__tests__/session-share-events.test.ts new file mode 100644 index 0000000..89f4aaf --- /dev/null +++ b/src/services/__tests__/session-share-events.test.ts @@ -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'); + }); +}); diff --git a/src/services/__tests__/teams.test.ts b/src/services/__tests__/teams.test.ts new file mode 100644 index 0000000..946ec6e --- /dev/null +++ b/src/services/__tests__/teams.test.ts @@ -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, + }); + }); +}); diff --git a/src/services/__tests__/weather.test.ts b/src/services/__tests__/weather.test.ts new file mode 100644 index 0000000..2dae717 --- /dev/null +++ b/src/services/__tests__/weather.test.ts @@ -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'); + }); +}); diff --git a/src/services/__tests__/workshops.test.ts b/src/services/__tests__/workshops.test.ts new file mode 100644 index 0000000..ba4fe86 --- /dev/null +++ b/src/services/__tests__/workshops.test.ts @@ -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 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a76743a --- /dev/null +++ b/vitest.config.ts @@ -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'], + }, + }, +});