test: add unit test coverage for services and lib
- 255 tests across 14 files (was 70 tests in 4 files) - src/services/__tests__: auth (registerUser, updateUserPassword, updateUserProfile), okrs (calculateOKRProgress, createOKR, updateKeyResult, updateOKR), teams (createTeam, addTeamMember, isAdminOfUser, getTeamMemberIdsForAdminTeams, getUserTeams), weather (getPreviousWeatherEntriesForUsers, shareWeatherSessionToTeam, getWeatherSessionsHistory), workshops (createSwotItem, duplicateSwotItem, updateAction, createMotivatorSession, updateCardInfluence, addGifMoodItem, shareGifMoodSessionToTeam, getLatestEventTimestamp, cleanupOldEvents) - src/lib/__tests__: date-utils, weather-utils, okr-utils, gravatar, workshops, share-utils - Update vitest coverage to include src/lib/** Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
117
src/lib/__tests__/date-utils.test.ts
Normal file
117
src/lib/__tests__/date-utils.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getISOWeek, getWeekYearLabel, getWeekBounds } from '@/lib/date-utils';
|
||||
|
||||
// ── getISOWeek ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getISOWeek', () => {
|
||||
it('returns week 1 for January 4 (always in ISO week 1)', () => {
|
||||
expect(getISOWeek(new Date(2026, 0, 4))).toBe(1);
|
||||
expect(getISOWeek(new Date(2024, 0, 4))).toBe(1);
|
||||
});
|
||||
|
||||
it('returns week 1 for Jan 1 when Jan 1 is Thursday (2026)', () => {
|
||||
// Jan 1, 2026 is a Thursday → week 1
|
||||
expect(getISOWeek(new Date(2026, 0, 1))).toBe(1);
|
||||
});
|
||||
|
||||
it('returns week 53 for Dec 31 when it falls in last ISO week', () => {
|
||||
// Dec 31, 2020 is a Thursday → week 53 of 2020
|
||||
expect(getISOWeek(new Date(2020, 11, 31))).toBe(53);
|
||||
});
|
||||
|
||||
it('returns correct week for a known mid-year date', () => {
|
||||
// Mar 10, 2026 is in week 11
|
||||
expect(getISOWeek(new Date(2026, 2, 10))).toBe(11);
|
||||
});
|
||||
|
||||
it('returns week 52 for Dec 28 (always in ISO week 52 or 53)', () => {
|
||||
// Dec 28 is always in the last week of the year per ISO
|
||||
const week = getISOWeek(new Date(2026, 11, 28));
|
||||
expect(week).toBeGreaterThanOrEqual(52);
|
||||
});
|
||||
|
||||
it('week advances by 1 between consecutive Mondays', () => {
|
||||
const w1 = getISOWeek(new Date(2026, 2, 9)); // Monday March 9
|
||||
const w2 = getISOWeek(new Date(2026, 2, 16)); // Monday March 16
|
||||
expect(w2 - w1).toBe(1);
|
||||
});
|
||||
|
||||
it('same week for all days Monday through Saturday of a given week', () => {
|
||||
// Week of March 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);
|
||||
});
|
||||
});
|
||||
57
src/lib/__tests__/gravatar.test.ts
Normal file
57
src/lib/__tests__/gravatar.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// ── getGravatarUrl ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGravatarUrl', () => {
|
||||
it('generates a valid gravatar URL', () => {
|
||||
const url = getGravatarUrl('test@example.com');
|
||||
expect(url).toMatch(/^https:\/\/www\.gravatar\.com\/avatar\/[0-9a-f]{32}\?d=identicon&s=\d+$/);
|
||||
});
|
||||
|
||||
it('uses MD5 hash of lowercased/trimmed email', () => {
|
||||
const email = 'Test@Example.COM';
|
||||
const hash = createHash('md5').update(email.toLowerCase().trim()).digest('hex');
|
||||
const url = getGravatarUrl(email);
|
||||
expect(url).toContain(`/avatar/${hash}`);
|
||||
});
|
||||
|
||||
it('produces same URL regardless of email case', () => {
|
||||
const lower = getGravatarUrl('user@example.com');
|
||||
const upper = getGravatarUrl('USER@EXAMPLE.COM');
|
||||
expect(lower).toBe(upper);
|
||||
});
|
||||
|
||||
it('uses default size of 40', () => {
|
||||
const url = getGravatarUrl('test@example.com');
|
||||
expect(url).toContain('s=40');
|
||||
});
|
||||
|
||||
it('uses custom size when provided', () => {
|
||||
const url = getGravatarUrl('test@example.com', 80);
|
||||
expect(url).toContain('s=80');
|
||||
});
|
||||
|
||||
it('uses identicon as default fallback', () => {
|
||||
const url = getGravatarUrl('test@example.com');
|
||||
expect(url).toContain('d=identicon');
|
||||
});
|
||||
|
||||
it('uses custom fallback when provided', () => {
|
||||
const url = getGravatarUrl('test@example.com', 40, 'retro');
|
||||
expect(url).toContain('d=retro');
|
||||
});
|
||||
|
||||
it('produces different hashes for different emails', () => {
|
||||
const url1 = getGravatarUrl('alice@example.com');
|
||||
const url2 = getGravatarUrl('bob@example.com');
|
||||
expect(url1).not.toBe(url2);
|
||||
});
|
||||
|
||||
it('trims whitespace from email before hashing', () => {
|
||||
const trimmed = getGravatarUrl('user@example.com');
|
||||
const padded = getGravatarUrl(' user@example.com ');
|
||||
expect(trimmed).toBe(padded);
|
||||
});
|
||||
});
|
||||
105
src/lib/__tests__/okr-utils.test.ts
Normal file
105
src/lib/__tests__/okr-utils.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod, comparePeriods } from '@/lib/okr-utils';
|
||||
|
||||
// ── getCurrentQuarterPeriod ────────────────────────────────────────────────
|
||||
|
||||
describe('getCurrentQuarterPeriod', () => {
|
||||
it('returns Q1 for January', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 0, 15))).toBe('Q1 2026');
|
||||
});
|
||||
|
||||
it('returns Q1 for March', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 2, 31))).toBe('Q1 2026');
|
||||
});
|
||||
|
||||
it('returns Q2 for April', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 3, 1))).toBe('Q2 2026');
|
||||
});
|
||||
|
||||
it('returns Q2 for June', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 5, 30))).toBe('Q2 2026');
|
||||
});
|
||||
|
||||
it('returns Q3 for July', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 6, 1))).toBe('Q3 2026');
|
||||
});
|
||||
|
||||
it('returns Q4 for October', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 9, 1))).toBe('Q4 2026');
|
||||
});
|
||||
|
||||
it('returns Q4 for December', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2026, 11, 31))).toBe('Q4 2026');
|
||||
});
|
||||
|
||||
it('includes the correct year', () => {
|
||||
expect(getCurrentQuarterPeriod(new Date(2025, 0, 1))).toBe('Q1 2025');
|
||||
expect(getCurrentQuarterPeriod(new Date(2027, 6, 1))).toBe('Q3 2027');
|
||||
});
|
||||
});
|
||||
|
||||
// ── isCurrentQuarterPeriod ─────────────────────────────────────────────────
|
||||
|
||||
describe('isCurrentQuarterPeriod', () => {
|
||||
it('returns true for matching period and date', () => {
|
||||
expect(isCurrentQuarterPeriod('Q1 2026', new Date(2026, 1, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different quarter', () => {
|
||||
expect(isCurrentQuarterPeriod('Q2 2026', new Date(2026, 0, 15))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for different year', () => {
|
||||
expect(isCurrentQuarterPeriod('Q1 2025', new Date(2026, 0, 15))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isCurrentQuarterPeriod('', new Date(2026, 0, 15))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── comparePeriods ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('comparePeriods', () => {
|
||||
it('returns 0 for identical periods', () => {
|
||||
expect(comparePeriods('Q1 2026', 'Q1 2026')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns negative when a is more recent than b (a should sort first)', () => {
|
||||
// Q2 2026 is more recent than Q1 2026
|
||||
expect(comparePeriods('Q2 2026', 'Q1 2026')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('returns positive when a is older than b (a should sort after)', () => {
|
||||
expect(comparePeriods('Q1 2026', 'Q2 2026')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sorts by year before quarter', () => {
|
||||
// Q4 2025 is older than Q1 2026
|
||||
expect(comparePeriods('Q4 2025', 'Q1 2026')).toBeGreaterThan(0);
|
||||
expect(comparePeriods('Q1 2026', 'Q4 2025')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('handles same year, different quarters correctly', () => {
|
||||
expect(comparePeriods('Q4 2026', 'Q1 2026')).toBeLessThan(0);
|
||||
expect(comparePeriods('Q1 2026', 'Q4 2026')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('puts parseable period before unparseable one', () => {
|
||||
// 'Q1 2026' can be parsed, 'custom period' cannot
|
||||
expect(comparePeriods('Q1 2026', 'custom period')).toBeLessThan(0);
|
||||
expect(comparePeriods('custom period', 'Q1 2026')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to string comparison when neither is parseable', () => {
|
||||
const result = comparePeriods('beta', 'alpha');
|
||||
// String descending: 'beta' > 'alpha' → b.localeCompare(a) = 'alpha'.localeCompare('beta') < 0
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('produces correct sort order for a mixed list', () => {
|
||||
const periods = ['Q1 2025', 'Q4 2026', 'Q2 2026', 'Q1 2026'];
|
||||
const sorted = [...periods].sort(comparePeriods);
|
||||
expect(sorted).toEqual(['Q4 2026', 'Q2 2026', 'Q1 2026', 'Q1 2025']);
|
||||
});
|
||||
});
|
||||
69
src/lib/__tests__/share-utils.test.ts
Normal file
69
src/lib/__tests__/share-utils.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getTeamMembersForShare } from '@/lib/share-utils';
|
||||
|
||||
const alice = { id: 'u1', email: 'alice@ex.com', name: 'Alice' };
|
||||
const bob = { id: 'u2', email: 'bob@ex.com', name: 'Bob' };
|
||||
const charlie = { id: 'u3', email: 'charlie@ex.com', name: 'Charlie' };
|
||||
|
||||
// ── getTeamMembersForShare ─────────────────────────────────────────────────
|
||||
|
||||
describe('getTeamMembersForShare', () => {
|
||||
it('returns empty array when teams have no members', () => {
|
||||
const result = getTeamMembersForShare([{ id: 't1', name: 'Team', description: null, members: [] }], 'u1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when all members are the current user', () => {
|
||||
const teams = [{ id: 't1', name: 'Team', description: null, members: [{ user: alice }] }];
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes the current user from results', () => {
|
||||
const teams = [{ id: 't1', name: 'Team', description: null, members: [{ user: alice }, { user: bob }] }];
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(bob.id);
|
||||
});
|
||||
|
||||
it('deduplicates users appearing in multiple teams', () => {
|
||||
const teams = [
|
||||
{ id: 't1', name: 'Team A', description: null, members: [{ user: bob }, { user: charlie }] },
|
||||
{ id: 't2', name: 'Team B', description: null, members: [{ user: bob }] }, // bob repeated
|
||||
];
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
const ids = result.map((u) => u.id);
|
||||
expect(ids).toContain(bob.id);
|
||||
expect(ids).toContain(charlie.id);
|
||||
expect(ids.filter((id) => id === bob.id)).toHaveLength(1); // no duplicate
|
||||
});
|
||||
|
||||
it('returns all unique members across teams excluding current user', () => {
|
||||
const teams = [
|
||||
{ id: 't1', name: 'T1', description: null, members: [{ user: alice }, { user: bob }] },
|
||||
{ id: 't2', name: 'T2', description: null, members: [{ user: charlie }] },
|
||||
];
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
const ids = result.map((u) => u.id);
|
||||
expect(ids).not.toContain(alice.id);
|
||||
expect(ids).toContain(bob.id);
|
||||
expect(ids).toContain(charlie.id);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles teams without members property (undefined)', () => {
|
||||
const teams = [{ id: 't1', name: 'Team', description: null }]; // no members key
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no teams provided', () => {
|
||||
expect(getTeamMembersForShare([], 'u1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves user fields (id, email, name)', () => {
|
||||
const teams = [{ id: 't1', name: 'T', description: null, members: [{ user: bob }] }];
|
||||
const result = getTeamMembersForShare(teams, alice.id);
|
||||
expect(result[0]).toEqual(bob);
|
||||
});
|
||||
});
|
||||
117
src/lib/__tests__/weather-utils.test.ts
Normal file
117
src/lib/__tests__/weather-utils.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getEmojiScore, getAverageEmoji, getEmojiEvolution, WEATHER_EMOJIS } from '@/lib/weather-utils';
|
||||
|
||||
// ── getEmojiScore ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEmojiScore', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(getEmojiScore(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(getEmojiScore(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string (Aucun = index 0)', () => {
|
||||
expect(getEmojiScore('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown emoji', () => {
|
||||
expect(getEmojiScore('🦄')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 1 for ☀️ (first scored emoji)', () => {
|
||||
expect(getEmojiScore('☀️')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 19 for ✨ (last emoji in list)', () => {
|
||||
expect(getEmojiScore('✨')).toBe(19);
|
||||
});
|
||||
|
||||
it('returns consistent 1-based index for all emojis in WEATHER_EMOJIS', () => {
|
||||
for (let i = 1; i < WEATHER_EMOJIS.length; i++) {
|
||||
expect(getEmojiScore(WEATHER_EMOJIS[i].emoji)).toBe(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAverageEmoji ───────────────────────────────────────────────────────
|
||||
|
||||
describe('getAverageEmoji', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(getAverageEmoji([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all values are null/undefined', () => {
|
||||
expect(getAverageEmoji([null, undefined, null])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all emojis are unknown (score null)', () => {
|
||||
expect(getAverageEmoji(['🦄', '🤖'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the same emoji when only one is provided', () => {
|
||||
expect(getAverageEmoji(['☀️'])).toBe('☀️');
|
||||
});
|
||||
|
||||
it('ignores null values and uses only scored emojis', () => {
|
||||
// Only ☀️ (1) is valid
|
||||
expect(getAverageEmoji([null, '☀️', null])).toBe('☀️');
|
||||
});
|
||||
|
||||
it('returns emoji closest to the average score', () => {
|
||||
// ☀️ = 1, ☁️ = 4 → avg = 2.5 → closest is 🌤️(2) or ⛅(3), both at dist 0.5
|
||||
// Code picks 🌤️ (lower index wins on tie)
|
||||
const result = getAverageEmoji(['☀️', '☁️']);
|
||||
expect(['🌤️', '⛅']).toContain(result);
|
||||
});
|
||||
|
||||
it('returns exact match when average is a whole number score', () => {
|
||||
// ☀️ = 1, 🌤️ = 2 → avg = 1.5 → dist(☀️)=0.5, dist(🌤️)=0.5 → ☀️ wins (lower index)
|
||||
expect(getAverageEmoji(['☀️', '🌤️'])).toBe('☀️');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEmojiEvolution ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getEmojiEvolution', () => {
|
||||
it('returns null when current is null', () => {
|
||||
expect(getEmojiEvolution(null, '☀️')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when previous is null', () => {
|
||||
expect(getEmojiEvolution('☀️', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when both are null', () => {
|
||||
expect(getEmojiEvolution(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when current is unknown emoji', () => {
|
||||
expect(getEmojiEvolution('🦄', '☀️')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when previous is unknown emoji', () => {
|
||||
expect(getEmojiEvolution('☀️', '🦄')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "same" when both emojis are identical', () => {
|
||||
expect(getEmojiEvolution('☀️', '☀️')).toBe('same');
|
||||
});
|
||||
|
||||
it('returns "up" when current score is lower (better weather)', () => {
|
||||
// ☀️ = 1 (better), 🌧️ = 6 (worse)
|
||||
// going from 🌧️ to ☀️: delta = 1 - 6 = -5 → "up"
|
||||
expect(getEmojiEvolution('☀️', '🌧️')).toBe('up');
|
||||
});
|
||||
|
||||
it('returns "down" when current score is higher (worse weather)', () => {
|
||||
// going from ☀️ to 🌧️: delta = 6 - 1 = 5 → "down"
|
||||
expect(getEmojiEvolution('🌧️', '☀️')).toBe('down');
|
||||
});
|
||||
|
||||
it('handles adjacent emojis', () => {
|
||||
// ☀️(1) → 🌤️(2): delta = 2-1 = 1 → "down" (slight degradation)
|
||||
expect(getEmojiEvolution('🌤️', '☀️')).toBe('down');
|
||||
});
|
||||
});
|
||||
140
src/lib/__tests__/workshops.test.ts
Normal file
140
src/lib/__tests__/workshops.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
WORKSHOPS,
|
||||
WORKSHOP_BY_ID,
|
||||
WORKSHOP_TYPE_IDS,
|
||||
VALID_TAB_PARAMS,
|
||||
getWorkshop,
|
||||
getSessionPath,
|
||||
getSessionsTabUrl,
|
||||
withWorkshopType,
|
||||
} from '@/lib/workshops';
|
||||
|
||||
// ── Registry integrity ─────────────────────────────────────────────────────
|
||||
|
||||
describe('WORKSHOPS registry', () => {
|
||||
it('contains exactly 6 workshop types', () => {
|
||||
expect(WORKSHOPS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('all workshop IDs match WORKSHOP_TYPE_IDS', () => {
|
||||
const ids = WORKSHOPS.map((w) => w.id);
|
||||
expect(ids).toEqual([...WORKSHOP_TYPE_IDS]);
|
||||
});
|
||||
|
||||
it('every workshop has required fields', () => {
|
||||
for (const w of WORKSHOPS) {
|
||||
expect(w.id).toBeTruthy();
|
||||
expect(w.path).toMatch(/^\//);
|
||||
expect(w.newPath).toMatch(/^\//);
|
||||
expect(w.label).toBeTruthy();
|
||||
expect(w.icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('workshops with participant have non-empty participantLabel', () => {
|
||||
for (const w of WORKSHOPS) {
|
||||
if (w.hasParticipant) {
|
||||
expect(w.participantLabel).toBeTruthy();
|
||||
} else {
|
||||
expect(w.participantLabel).toBe('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every workshop has home content with at least 1 feature', () => {
|
||||
for (const w of WORKSHOPS) {
|
||||
expect(w.home.tagline).toBeTruthy();
|
||||
expect(w.home.description).toBeTruthy();
|
||||
expect(w.home.features.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('WORKSHOP_BY_ID', () => {
|
||||
it('contains an entry for every workshop type', () => {
|
||||
for (const id of WORKSHOP_TYPE_IDS) {
|
||||
expect(WORKSHOP_BY_ID[id]).toBeDefined();
|
||||
expect(WORKSHOP_BY_ID[id].id).toBe(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALID_TAB_PARAMS', () => {
|
||||
it('includes all workshop IDs plus "all", "byPerson", "team"', () => {
|
||||
expect(VALID_TAB_PARAMS).toContain('all');
|
||||
expect(VALID_TAB_PARAMS).toContain('byPerson');
|
||||
expect(VALID_TAB_PARAMS).toContain('team');
|
||||
for (const id of WORKSHOP_TYPE_IDS) {
|
||||
expect(VALID_TAB_PARAMS).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── getWorkshop ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getWorkshop', () => {
|
||||
it('returns the correct workshop config', () => {
|
||||
const swot = getWorkshop('swot');
|
||||
expect(swot.id).toBe('swot');
|
||||
expect(swot.path).toBe('/sessions');
|
||||
});
|
||||
|
||||
it('returns different configs for different IDs', () => {
|
||||
expect(getWorkshop('swot').path).not.toBe(getWorkshop('motivators').path);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSessionPath ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getSessionPath', () => {
|
||||
it('builds path for swot', () => {
|
||||
expect(getSessionPath('swot', 'abc123')).toBe('/sessions/abc123');
|
||||
});
|
||||
|
||||
it('builds path for motivators', () => {
|
||||
expect(getSessionPath('motivators', 'xyz')).toBe('/motivators/xyz');
|
||||
});
|
||||
|
||||
it('builds path for weather', () => {
|
||||
expect(getSessionPath('weather', 'w-1')).toBe('/weather/w-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSessionsTabUrl ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getSessionsTabUrl', () => {
|
||||
it('generates correct tab URL', () => {
|
||||
expect(getSessionsTabUrl('swot')).toBe('/sessions?tab=swot');
|
||||
expect(getSessionsTabUrl('motivators')).toBe('/sessions?tab=motivators');
|
||||
expect(getSessionsTabUrl('weather')).toBe('/sessions?tab=weather');
|
||||
});
|
||||
});
|
||||
|
||||
// ── withWorkshopType ───────────────────────────────────────────────────────
|
||||
|
||||
describe('withWorkshopType', () => {
|
||||
it('adds workshopType to each item', () => {
|
||||
const sessions = [{ id: 'a' }, { id: 'b' }];
|
||||
const result = withWorkshopType(sessions, 'swot');
|
||||
expect(result[0]).toEqual({ id: 'a', workshopType: 'swot' });
|
||||
expect(result[1]).toEqual({ id: 'b', workshopType: 'swot' });
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(withWorkshopType([], 'motivators')).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves all original properties', () => {
|
||||
const sessions = [{ id: 'x', title: 'Test', createdAt: new Date() }];
|
||||
const result = withWorkshopType(sessions, 'year-review');
|
||||
expect(result[0].title).toBe('Test');
|
||||
expect(result[0].workshopType).toBe('year-review');
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const sessions = [{ id: 'a' }];
|
||||
withWorkshopType(sessions, 'swot');
|
||||
expect(sessions[0]).not.toHaveProperty('workshopType');
|
||||
});
|
||||
});
|
||||
290
src/services/__tests__/auth.test.ts
Normal file
290
src/services/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
batchResolveCollaborators,
|
||||
resolveCollaborator,
|
||||
registerUser,
|
||||
updateUserPassword,
|
||||
updateUserProfile,
|
||||
} from '@/services/auth';
|
||||
|
||||
vi.mock('@/services/database', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
hash: vi.fn().mockResolvedValue('hashed_password'),
|
||||
compare: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mock to get the mocked version
|
||||
const { prisma } = await import('@/services/database');
|
||||
const mockFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockFindFirst = vi.mocked(prisma.user.findFirst);
|
||||
const mockCreate = vi.mocked(prisma.user.create);
|
||||
const mockUpdate = vi.mocked(prisma.user.update);
|
||||
|
||||
const { hash: mockHash, compare: mockCompare } = await import('bcryptjs');
|
||||
const mockHashFn = vi.mocked(mockHash);
|
||||
const mockCompareFn = vi.mocked(mockCompare);
|
||||
|
||||
const alice = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
|
||||
const bob = { id: 'u2', email: 'bob@example.com', name: 'Bob' };
|
||||
|
||||
// ── batchResolveCollaborators ──────────────────────────────────────────────
|
||||
|
||||
describe('batchResolveCollaborators', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns empty map for empty input without DB calls', async () => {
|
||||
const result = await batchResolveCollaborators([]);
|
||||
expect(result.size).toBe(0);
|
||||
expect(mockFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves an email to its user', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([alice]); // email query
|
||||
const result = await batchResolveCollaborators(['alice@example.com']);
|
||||
expect(result.get('alice@example.com')).toEqual({ raw: 'alice@example.com', matchedUser: alice });
|
||||
});
|
||||
|
||||
it('email matching is case-insensitive', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([alice]); // DB returns lowercase email
|
||||
const result = await batchResolveCollaborators(['Alice@Example.COM']);
|
||||
expect(result.get('Alice@Example.COM')?.matchedUser).toEqual(alice);
|
||||
});
|
||||
|
||||
it('resolves a name to its user', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([bob]); // name query (no emails → 1 call)
|
||||
const result = await batchResolveCollaborators(['Bob']);
|
||||
expect(result.get('Bob')).toEqual({ raw: 'Bob', matchedUser: bob });
|
||||
});
|
||||
|
||||
it('name matching is case-insensitive', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([bob]);
|
||||
const result = await batchResolveCollaborators(['BOB']);
|
||||
expect(result.get('BOB')?.matchedUser).toEqual(bob);
|
||||
});
|
||||
|
||||
it('returns null matchedUser when email not found', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]); // no match
|
||||
const result = await batchResolveCollaborators(['nobody@example.com']);
|
||||
expect(result.get('nobody@example.com')).toEqual({ raw: 'nobody@example.com', matchedUser: null });
|
||||
});
|
||||
|
||||
it('returns null matchedUser when name not found', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
const result = await batchResolveCollaborators(['Unknown']);
|
||||
expect(result.get('Unknown')).toEqual({ raw: 'Unknown', matchedUser: null });
|
||||
});
|
||||
|
||||
it('handles mixed emails and names in exactly 2 DB queries', async () => {
|
||||
mockFindMany
|
||||
.mockResolvedValueOnce([alice]) // email query
|
||||
.mockResolvedValueOnce([bob]); // name query
|
||||
const result = await batchResolveCollaborators(['alice@example.com', 'Bob']);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(2);
|
||||
expect(result.get('alice@example.com')?.matchedUser).toEqual(alice);
|
||||
expect(result.get('Bob')?.matchedUser).toEqual(bob);
|
||||
});
|
||||
|
||||
it('deduplicates identical inputs (only 1 DB entry per unique value)', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([alice]); // only emails → 1 call
|
||||
const result = await batchResolveCollaborators(['alice@example.com', 'alice@example.com']);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
expect(result.size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips email query when no emails present', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([bob]);
|
||||
await batchResolveCollaborators(['Bob']);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
expect(mockFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ OR: expect.any(Array) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips name query when no names present', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([alice]);
|
||||
await batchResolveCollaborators(['alice@example.com']);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
expect(mockFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ email: expect.any(Object) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles whitespace-padded inputs', async () => {
|
||||
mockFindMany.mockResolvedValueOnce([alice]);
|
||||
const result = await batchResolveCollaborators([' alice@example.com ']);
|
||||
expect(result.get('alice@example.com')?.matchedUser).toEqual(alice);
|
||||
});
|
||||
|
||||
it('resolves multiple unique names in one query', async () => {
|
||||
const charlie = { id: 'u3', email: 'c@example.com', name: 'Charlie' };
|
||||
mockFindMany.mockResolvedValueOnce([bob, charlie]);
|
||||
const result = await batchResolveCollaborators(['Bob', 'Charlie']);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
expect(result.get('Bob')?.matchedUser).toEqual(bob);
|
||||
expect(result.get('Charlie')?.matchedUser).toEqual(charlie);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveCollaborator ────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveCollaborator', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('resolves email via findUnique', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(alice);
|
||||
const result = await resolveCollaborator('alice@example.com');
|
||||
expect(result).toEqual({ raw: 'alice@example.com', matchedUser: alice });
|
||||
expect(mockFindUnique).toHaveBeenCalledWith({ where: { email: 'alice@example.com' }, select: expect.any(Object) });
|
||||
});
|
||||
|
||||
it('returns null matchedUser when email not found', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(null);
|
||||
const result = await resolveCollaborator('ghost@example.com');
|
||||
expect(result.matchedUser).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves name via findFirst when not an email', async () => {
|
||||
mockFindFirst.mockResolvedValueOnce(bob);
|
||||
const result = await resolveCollaborator('Bob');
|
||||
expect(result.matchedUser).toEqual(bob);
|
||||
});
|
||||
|
||||
it('returns null matchedUser when name not found', async () => {
|
||||
mockFindFirst.mockResolvedValueOnce(null);
|
||||
const result = await resolveCollaborator('Nobody');
|
||||
expect(result.matchedUser).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects partial name matches (contains but not exact)', async () => {
|
||||
// findFirst returns a user whose name doesn't match exactly
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 'u1', email: 'a@ex.com', name: 'Bobby' });
|
||||
const result = await resolveCollaborator('Bob');
|
||||
expect(result.matchedUser).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerUser ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('registerUser', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns error when email already exists', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(alice);
|
||||
const result = await registerUser({ email: 'alice@example.com', password: 'secret' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/existe déjà/);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hashes password and creates user on success', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(null);
|
||||
mockCreate.mockResolvedValueOnce({ id: 'new-id', email: 'new@example.com', name: null });
|
||||
const result = await registerUser({ email: 'new@example.com', password: 'secret' });
|
||||
expect(mockHashFn).toHaveBeenCalledWith('secret', 12);
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ email: 'new@example.com', password: 'hashed_password' }) })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.email).toBe('new@example.com');
|
||||
});
|
||||
|
||||
it('returns only id, email, name fields', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(null);
|
||||
mockCreate.mockResolvedValueOnce({ id: 'x', email: 'x@ex.com', name: 'X' });
|
||||
const result = await registerUser({ email: 'x@ex.com', password: 'p', name: 'X' });
|
||||
expect(result.user).toEqual({ id: 'x', email: 'x@ex.com', name: 'X' });
|
||||
});
|
||||
|
||||
it('sets name to null when not provided', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(null);
|
||||
mockCreate.mockResolvedValueOnce({ id: 'y', email: 'y@ex.com', name: null });
|
||||
await registerUser({ email: 'y@ex.com', password: 'p' });
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ name: null }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateUserPassword ────────────────────────────────────────────────────
|
||||
|
||||
describe('updateUserPassword', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns error when user not found', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce(null);
|
||||
const result = await updateUserPassword('u1', 'old', 'new');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/non trouvé/);
|
||||
});
|
||||
|
||||
it('returns error when current password is incorrect', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce({ id: 'u1', password: 'hashed_old' });
|
||||
mockCompareFn.mockResolvedValueOnce(false as never);
|
||||
const result = await updateUserPassword('u1', 'wrong', 'new');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/incorrect/);
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hashes new password and updates on success', async () => {
|
||||
mockFindUnique.mockResolvedValueOnce({ id: 'u1', password: 'hashed_old' });
|
||||
mockCompareFn.mockResolvedValueOnce(true as never);
|
||||
mockUpdate.mockResolvedValueOnce({});
|
||||
const result = await updateUserPassword('u1', 'old', 'newpass');
|
||||
expect(mockHashFn).toHaveBeenCalledWith('newpass', 12);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { password: 'hashed_password' } })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateUserProfile ─────────────────────────────────────────────────────
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns error when email is taken by another user', async () => {
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 'other', email: 'taken@ex.com' });
|
||||
const result = await updateUserProfile('u1', { email: 'taken@ex.com' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/déjà utilisé/);
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates name and email on success', async () => {
|
||||
mockFindFirst.mockResolvedValueOnce(null); // email not taken
|
||||
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'new@ex.com', name: 'New Name' });
|
||||
const result = await updateUserProfile('u1', { name: 'New Name', email: 'new@ex.com' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual({ id: 'u1', email: 'new@ex.com', name: 'New Name' });
|
||||
});
|
||||
|
||||
it('skips email uniqueness check when no email provided', async () => {
|
||||
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'old@ex.com', name: 'Updated' });
|
||||
const result = await updateUserProfile('u1', { name: 'Updated' });
|
||||
expect(mockFindFirst).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('passes correct query to check email uniqueness (excludes self)', async () => {
|
||||
mockFindFirst.mockResolvedValueOnce(null);
|
||||
mockUpdate.mockResolvedValueOnce({ id: 'u1', email: 'new@ex.com', name: null });
|
||||
await updateUserProfile('u1', { email: 'new@ex.com' });
|
||||
expect(mockFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ NOT: { id: 'u1' } }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
333
src/services/__tests__/okrs.test.ts
Normal file
333
src/services/__tests__/okrs.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createOKR, updateKeyResult, updateOKR, calculateOKRProgress } from '@/services/okrs';
|
||||
|
||||
vi.mock('@/services/database', () => {
|
||||
const mockTx = {
|
||||
oKR: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
keyResult: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
prisma: {
|
||||
oKR: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
keyResult: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => unknown) => {
|
||||
if (typeof fn === 'function') return fn(mockTx);
|
||||
return Promise.all(fn as Promise<unknown>[]);
|
||||
}),
|
||||
_mockTx: mockTx,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { prisma } = await import('@/services/database');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockTx = (prisma as any)._mockTx;
|
||||
|
||||
// ── calculateOKRProgress ──────────────────────────────────────────────────
|
||||
|
||||
describe('calculateOKRProgress', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns 0 when OKR not found', async () => {
|
||||
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce(null);
|
||||
const result = await calculateOKRProgress('okr-1');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for OKR with empty key results', async () => {
|
||||
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({ id: 'okr-1', keyResults: [] } as never);
|
||||
const result = await calculateOKRProgress('okr-1');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates average progress across key results', async () => {
|
||||
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
|
||||
id: 'okr-1',
|
||||
keyResults: [
|
||||
{ currentValue: 50, targetValue: 100 }, // 50%
|
||||
{ currentValue: 100, targetValue: 100 }, // 100%
|
||||
],
|
||||
} as never);
|
||||
const result = await calculateOKRProgress('okr-1');
|
||||
expect(result).toBe(75);
|
||||
});
|
||||
|
||||
it('caps individual KR progress at 100%', async () => {
|
||||
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
|
||||
id: 'okr-1',
|
||||
keyResults: [
|
||||
{ currentValue: 200, targetValue: 100 }, // over 100% → capped at 100
|
||||
{ currentValue: 0, targetValue: 100 }, // 0%
|
||||
],
|
||||
} as never);
|
||||
const result = await calculateOKRProgress('okr-1');
|
||||
expect(result).toBe(50); // (100 + 0) / 2
|
||||
});
|
||||
|
||||
it('returns 0 when targetValue is 0 (avoids division by zero)', async () => {
|
||||
vi.mocked(prisma.oKR.findUnique).mockResolvedValueOnce({
|
||||
id: 'okr-1',
|
||||
keyResults: [{ currentValue: 50, targetValue: 0 }],
|
||||
} as never);
|
||||
const result = await calculateOKRProgress('okr-1');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createOKR ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createOKR', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates OKR with NOT_STARTED status in transaction', async () => {
|
||||
const createdOKR = { id: 'okr-1', teamMemberId: 'tm-1', objective: 'Grow revenue', status: 'NOT_STARTED' };
|
||||
mockTx.oKR.create.mockResolvedValueOnce(createdOKR);
|
||||
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
|
||||
|
||||
const result = await createOKR(
|
||||
'tm-1',
|
||||
'Grow revenue',
|
||||
null,
|
||||
'Q1-2026',
|
||||
new Date(),
|
||||
new Date(),
|
||||
[{ title: 'KR1', targetValue: 100, unit: '%', order: 0 }]
|
||||
);
|
||||
|
||||
expect(vi.mocked(prisma.$transaction)).toHaveBeenCalledTimes(1);
|
||||
expect(mockTx.oKR.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'NOT_STARTED', teamMemberId: 'tm-1' }),
|
||||
})
|
||||
);
|
||||
expect(result).toMatchObject({ id: 'okr-1' });
|
||||
});
|
||||
|
||||
it('creates all key results with NOT_STARTED status', async () => {
|
||||
const createdOKR = { id: 'okr-1' };
|
||||
mockTx.oKR.create.mockResolvedValueOnce(createdOKR);
|
||||
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1', status: 'NOT_STARTED' });
|
||||
|
||||
const keyResults = [
|
||||
{ title: 'KR1', targetValue: 100, unit: '%', order: 0 },
|
||||
{ title: 'KR2', targetValue: 50, unit: 'units', order: 1 },
|
||||
];
|
||||
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), keyResults);
|
||||
|
||||
expect(mockTx.keyResult.create).toHaveBeenCalledTimes(2);
|
||||
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'NOT_STARTED', currentValue: 0 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses index as order when order is not provided', async () => {
|
||||
mockTx.oKR.create.mockResolvedValueOnce({ id: 'okr-1' });
|
||||
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
|
||||
|
||||
// Pass keyResults with explicit order
|
||||
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), [
|
||||
{ title: 'KR1', targetValue: 100, unit: '%', order: 5 },
|
||||
]);
|
||||
|
||||
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ order: 5 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults unit to % when unit is empty string', async () => {
|
||||
mockTx.oKR.create.mockResolvedValueOnce({ id: 'okr-1' });
|
||||
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-1' });
|
||||
|
||||
await createOKR('tm-1', 'Obj', null, 'Q1', new Date(), new Date(), [
|
||||
{ title: 'KR1', targetValue: 100, unit: '', order: 0 },
|
||||
]);
|
||||
|
||||
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ unit: '%' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateKeyResult ───────────────────────────────────────────────────────
|
||||
|
||||
describe('updateKeyResult', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeKR(currentValue: number, targetValue: number, okrStatus = 'NOT_STARTED') {
|
||||
return {
|
||||
id: 'kr-1',
|
||||
targetValue,
|
||||
notes: null,
|
||||
status: 'NOT_STARTED',
|
||||
okr: { id: 'okr-1', status: okrStatus, keyResults: [{ currentValue, targetValue }] },
|
||||
};
|
||||
}
|
||||
|
||||
it('throws when key result not found', async () => {
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(null);
|
||||
await expect(updateKeyResult('kr-1', 50, null)).rejects.toThrow('Key Result not found');
|
||||
});
|
||||
|
||||
it('sets status to NOT_STARTED when progress is 0', async () => {
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(0, 100) as never);
|
||||
await updateKeyResult('kr-1', 0, null);
|
||||
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'NOT_STARTED' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets status to AT_RISK when progress is below 50%', async () => {
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(30, 100) as never);
|
||||
await updateKeyResult('kr-1', 30, null);
|
||||
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'AT_RISK' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets status to IN_PROGRESS when progress is 50% or more (but less than 100%)', async () => {
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(70, 100) as never);
|
||||
await updateKeyResult('kr-1', 70, null);
|
||||
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'IN_PROGRESS' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets status to COMPLETED when progress reaches 100%', async () => {
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(makeKR(0, 100) as never);
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(makeKR(100, 100) as never);
|
||||
await updateKeyResult('kr-1', 100, null);
|
||||
expect(vi.mocked(prisma.keyResult.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'COMPLETED' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('updates OKR status to IN_PROGRESS when progress > 0', async () => {
|
||||
const kr = makeKR(0, 100, 'NOT_STARTED');
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
|
||||
// update returns KR with okr having keyResults at 50%
|
||||
const updatedKr = {
|
||||
...kr,
|
||||
okr: { id: 'okr-1', status: 'NOT_STARTED', keyResults: [{ currentValue: 50, targetValue: 100 }] },
|
||||
};
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
|
||||
|
||||
await updateKeyResult('kr-1', 50, null);
|
||||
|
||||
expect(vi.mocked(prisma.oKR.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { status: 'IN_PROGRESS' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('updates OKR status to COMPLETED when all KRs are 100%', async () => {
|
||||
const kr = makeKR(0, 100, 'IN_PROGRESS');
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
|
||||
const updatedKr = {
|
||||
...kr,
|
||||
okr: { id: 'okr-1', status: 'IN_PROGRESS', keyResults: [{ currentValue: 100, targetValue: 100 }] },
|
||||
};
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
|
||||
|
||||
await updateKeyResult('kr-1', 100, null);
|
||||
|
||||
expect(vi.mocked(prisma.oKR.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { status: 'COMPLETED' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update OKR status if it has not changed', async () => {
|
||||
const kr = makeKR(0, 100, 'IN_PROGRESS');
|
||||
vi.mocked(prisma.keyResult.findUnique).mockResolvedValueOnce(kr as never);
|
||||
const updatedKr = {
|
||||
...kr,
|
||||
okr: { id: 'okr-1', status: 'IN_PROGRESS', keyResults: [{ currentValue: 60, targetValue: 100 }] },
|
||||
};
|
||||
vi.mocked(prisma.keyResult.update).mockResolvedValueOnce(updatedKr as never);
|
||||
|
||||
await updateKeyResult('kr-1', 60, null);
|
||||
|
||||
expect(vi.mocked(prisma.oKR.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateOKR ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateOKR', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('updates OKR fields in transaction', async () => {
|
||||
mockTx.oKR.update.mockResolvedValueOnce({});
|
||||
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'IN_PROGRESS', keyResults: [] });
|
||||
|
||||
await updateOKR('okr-1', { objective: 'New objective', status: 'IN_PROGRESS' });
|
||||
|
||||
expect(mockTx.oKR.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'okr-1' },
|
||||
data: expect.objectContaining({ objective: 'New objective', status: 'IN_PROGRESS' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes key results when delete list provided', async () => {
|
||||
mockTx.oKR.update.mockResolvedValueOnce({});
|
||||
mockTx.keyResult.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'NOT_STARTED', keyResults: [] });
|
||||
|
||||
await updateOKR('okr-1', {}, { delete: ['kr-1', 'kr-2'] });
|
||||
|
||||
expect(mockTx.keyResult.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: { in: ['kr-1', 'kr-2'] }, okrId: 'okr-1' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates new key results when create list provided', async () => {
|
||||
mockTx.oKR.update.mockResolvedValueOnce({});
|
||||
mockTx.keyResult.create.mockResolvedValue({ id: 'kr-new' });
|
||||
mockTx.oKR.findUnique.mockResolvedValueOnce({ id: 'okr-1', status: 'NOT_STARTED', keyResults: [] });
|
||||
|
||||
await updateOKR('okr-1', {}, {
|
||||
create: [{ title: 'New KR', targetValue: 100, unit: '%', order: 0 }],
|
||||
});
|
||||
|
||||
expect(mockTx.keyResult.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ title: 'New KR', currentValue: 0, status: 'NOT_STARTED' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when OKR not found after update', async () => {
|
||||
mockTx.oKR.update.mockResolvedValueOnce({});
|
||||
mockTx.oKR.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(updateOKR('okr-1', {})).rejects.toThrow('OKR not found after update');
|
||||
});
|
||||
|
||||
it('includes progress in returned OKR', async () => {
|
||||
mockTx.oKR.update.mockResolvedValueOnce({});
|
||||
mockTx.oKR.findUnique.mockResolvedValueOnce({
|
||||
id: 'okr-1',
|
||||
status: 'IN_PROGRESS',
|
||||
keyResults: [{ currentValue: 50, targetValue: 100 }],
|
||||
});
|
||||
|
||||
const result = await updateOKR('okr-1', {});
|
||||
expect(result).toMatchObject({ id: 'okr-1', progress: 50 });
|
||||
});
|
||||
});
|
||||
156
src/services/__tests__/session-permissions.test.ts
Normal file
156
src/services/__tests__/session-permissions.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
createSessionPermissionChecks,
|
||||
withAdminFallback,
|
||||
canDeleteByOwner,
|
||||
} from '@/services/session-permissions';
|
||||
|
||||
vi.mock('@/services/teams', () => ({
|
||||
isAdminOfUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const { isAdminOfUser } = await import('@/services/teams');
|
||||
const mockIsAdminOfUser = vi.mocked(isAdminOfUser);
|
||||
|
||||
// Factory for mock Prisma model delegate
|
||||
function makeModel(countResult: number, ownerId: string | null = 'owner-1') {
|
||||
return {
|
||||
count: vi.fn().mockResolvedValue(countResult),
|
||||
findUnique: vi.fn().mockResolvedValue(ownerId ? { userId: ownerId } : null),
|
||||
};
|
||||
}
|
||||
|
||||
describe('createSessionPermissionChecks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsAdminOfUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('canAccess', () => {
|
||||
it('returns true when user has direct access (count > 0)', async () => {
|
||||
const { canAccess } = createSessionPermissionChecks(makeModel(1));
|
||||
expect(await canAccess('session-1', 'user-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when no direct access but user is team admin', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const { canAccess } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
|
||||
expect(await canAccess('session-1', 'admin-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no direct access and not admin', async () => {
|
||||
const { canAccess } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
|
||||
expect(await canAccess('session-1', 'stranger')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no direct access and session owner not found', async () => {
|
||||
const { canAccess } = createSessionPermissionChecks(makeModel(0, null));
|
||||
expect(await canAccess('session-1', 'anyone')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canEdit', () => {
|
||||
it('returns true when user is owner or editor (count > 0)', async () => {
|
||||
const { canEdit } = createSessionPermissionChecks(makeModel(1));
|
||||
expect(await canEdit('session-1', 'editor-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for viewer (count = 0) when not admin', async () => {
|
||||
const { canEdit } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
|
||||
expect(await canEdit('session-1', 'viewer-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for viewer when user is team admin', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const { canEdit } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
|
||||
expect(await canEdit('session-1', 'admin-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDelete', () => {
|
||||
it('returns true for session owner', async () => {
|
||||
const { canDelete } = createSessionPermissionChecks(makeModel(1, 'owner-1'));
|
||||
expect(await canDelete('session-1', 'owner-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-owner even with EDITOR role', async () => {
|
||||
const { canDelete } = createSessionPermissionChecks(makeModel(1, 'owner-1'));
|
||||
expect(await canDelete('session-1', 'editor-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user is team admin of the owner', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const { canDelete } = createSessionPermissionChecks(makeModel(0, 'owner-1'));
|
||||
expect(await canDelete('session-1', 'admin-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when session not found', async () => {
|
||||
const { canDelete } = createSessionPermissionChecks(makeModel(0, null));
|
||||
expect(await canDelete('session-1', 'anyone')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAdminFallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsAdminOfUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns true immediately when hasDirectAccess is true (no admin check)', async () => {
|
||||
const getOwnerId = vi.fn();
|
||||
const result = await withAdminFallback(true, getOwnerId, 'session-1', 'user-1');
|
||||
expect(result).toBe(true);
|
||||
expect(getOwnerId).not.toHaveBeenCalled();
|
||||
expect(mockIsAdminOfUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to admin check when no direct access', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
|
||||
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'admin-1');
|
||||
expect(result).toBe(true);
|
||||
expect(mockIsAdminOfUser).toHaveBeenCalledWith('owner-1', 'admin-1');
|
||||
});
|
||||
|
||||
it('returns false when no direct access and not admin', async () => {
|
||||
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
|
||||
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'stranger');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when owner not found', async () => {
|
||||
const getOwnerId = vi.fn().mockResolvedValue(null);
|
||||
const result = await withAdminFallback(false, getOwnerId, 'session-1', 'anyone');
|
||||
expect(result).toBe(false);
|
||||
expect(mockIsAdminOfUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDeleteByOwner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsAdminOfUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns true when userId matches ownerId', async () => {
|
||||
const getOwnerId = vi.fn().mockResolvedValue('user-1');
|
||||
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'user-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when userId does not match and not admin', async () => {
|
||||
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
|
||||
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'other')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user is admin of owner', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const getOwnerId = vi.fn().mockResolvedValue('owner-1');
|
||||
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'admin-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when session not found (ownerId is null)', async () => {
|
||||
const getOwnerId = vi.fn().mockResolvedValue(null);
|
||||
expect(await canDeleteByOwner(getOwnerId, 'session-1', 'anyone')).toBe(false);
|
||||
});
|
||||
});
|
||||
248
src/services/__tests__/session-queries.test.ts
Normal file
248
src/services/__tests__/session-queries.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
mergeSessionsByUserId,
|
||||
fetchTeamCollaboratorSessions,
|
||||
getSessionByIdGeneric,
|
||||
} from '@/services/session-queries';
|
||||
|
||||
vi.mock('@/services/teams', () => ({
|
||||
isAdminOfUser: vi.fn().mockResolvedValue(false),
|
||||
getTeamMemberIdsForAdminTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const { isAdminOfUser } = await import('@/services/teams');
|
||||
const mockIsAdminOfUser = vi.mocked(isAdminOfUser);
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────
|
||||
|
||||
const USER_ID = 'user-1';
|
||||
|
||||
function makeSession(overrides: Partial<{
|
||||
id: string; updatedAt: Date; userId: string;
|
||||
shares: Array<{ userId: string; role?: string }>;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 's1',
|
||||
updatedAt: new Date('2024-06-01'),
|
||||
userId: USER_ID,
|
||||
user: { id: USER_ID, name: 'Alice', email: 'alice@example.com' },
|
||||
shares: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── mergeSessionsByUserId ──────────────────────────────────────────────────
|
||||
|
||||
describe('mergeSessionsByUserId', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('marks owned sessions with isOwner=true and role=OWNER', async () => {
|
||||
const s = makeSession();
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([s]),
|
||||
vi.fn().mockResolvedValue([]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ id: 's1', isOwner: true, role: 'OWNER' });
|
||||
});
|
||||
|
||||
it('marks shared sessions with isOwner=false and role from share', async () => {
|
||||
const s = makeSession({ id: 's2', userId: 'other' });
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([]),
|
||||
vi.fn().mockResolvedValue([{ session: s, role: 'VIEWER', createdAt: new Date() }]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result[0]).toMatchObject({ id: 's2', isOwner: false, role: 'VIEWER' });
|
||||
});
|
||||
|
||||
it('sorts merged list by updatedAt descending', async () => {
|
||||
const older = makeSession({ id: 's1', updatedAt: new Date('2024-01-01') });
|
||||
const newer = makeSession({ id: 's2', updatedAt: new Date('2024-06-01') });
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([older, newer]),
|
||||
vi.fn().mockResolvedValue([]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result[0].id).toBe('s2');
|
||||
expect(result[1].id).toBe('s1');
|
||||
});
|
||||
|
||||
it('merges owned and shared sessions together', async () => {
|
||||
const owned = makeSession({ id: 'own' });
|
||||
const shared = makeSession({ id: 'shared', userId: 'other', updatedAt: new Date('2020-01-01') });
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([owned]),
|
||||
vi.fn().mockResolvedValue([{ session: shared, role: 'EDITOR', createdAt: new Date() }]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((s) => s.id)).toContain('own');
|
||||
expect(result.map((s) => s.id)).toContain('shared');
|
||||
});
|
||||
|
||||
it('returns empty array when no sessions', async () => {
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([]),
|
||||
vi.fn().mockResolvedValue([]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies resolveParticipant callback to each session', async () => {
|
||||
const s = makeSession();
|
||||
const resolveParticipant = vi.fn().mockResolvedValue({ extra: 'data' });
|
||||
const result = await mergeSessionsByUserId(
|
||||
vi.fn().mockResolvedValue([s]),
|
||||
vi.fn().mockResolvedValue([]),
|
||||
USER_ID,
|
||||
resolveParticipant
|
||||
);
|
||||
expect(resolveParticipant).toHaveBeenCalledWith(expect.objectContaining({ id: 's1' }));
|
||||
expect((result[0] as typeof result[0] & { extra: string }).extra).toBe('data');
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchTeamCollaboratorSessions ──────────────────────────────────────────
|
||||
|
||||
describe('fetchTeamCollaboratorSessions', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns empty array when team has no members', async () => {
|
||||
const result = await fetchTeamCollaboratorSessions(
|
||||
vi.fn(),
|
||||
vi.fn().mockResolvedValue([]),
|
||||
USER_ID
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks sessions with isTeamCollab=true, canEdit=true, role=VIEWER', async () => {
|
||||
const s = makeSession({ id: 'team-s', userId: 'member-1' });
|
||||
const result = await fetchTeamCollaboratorSessions(
|
||||
vi.fn().mockResolvedValue([s]),
|
||||
vi.fn().mockResolvedValue(['member-1']),
|
||||
USER_ID
|
||||
);
|
||||
expect(result[0]).toMatchObject({ id: 'team-s', isOwner: false, role: 'VIEWER', isTeamCollab: true, canEdit: true });
|
||||
});
|
||||
|
||||
it('calls fetchTeamSessions with team member ids', async () => {
|
||||
const fetchTeamSessions = vi.fn().mockResolvedValue([]);
|
||||
await fetchTeamCollaboratorSessions(
|
||||
fetchTeamSessions,
|
||||
vi.fn().mockResolvedValue(['m1', 'm2']),
|
||||
USER_ID
|
||||
);
|
||||
expect(fetchTeamSessions).toHaveBeenCalledWith(['m1', 'm2'], USER_ID);
|
||||
});
|
||||
|
||||
it('applies resolveParticipant callback', async () => {
|
||||
const s = makeSession({ id: 't-s', userId: 'member-1' });
|
||||
const resolveParticipant = vi.fn().mockResolvedValue({ resolved: true });
|
||||
const result = await fetchTeamCollaboratorSessions(
|
||||
vi.fn().mockResolvedValue([s]),
|
||||
vi.fn().mockResolvedValue(['member-1']),
|
||||
USER_ID,
|
||||
resolveParticipant
|
||||
);
|
||||
expect(resolveParticipant).toHaveBeenCalled();
|
||||
expect((result[0] as typeof result[0] & { resolved: boolean }).resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSessionByIdGeneric ──────────────────────────────────────────────────
|
||||
|
||||
describe('getSessionByIdGeneric', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsAdminOfUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns session with isOwner=true when user is owner', async () => {
|
||||
const s = makeSession({ userId: USER_ID, shares: [] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', USER_ID,
|
||||
vi.fn().mockResolvedValue(s),
|
||||
vi.fn()
|
||||
);
|
||||
expect(result).toMatchObject({ isOwner: true, role: 'OWNER', canEdit: true });
|
||||
});
|
||||
|
||||
it('returns session with EDITOR role and canEdit=true for editor share', async () => {
|
||||
const s = makeSession({ userId: 'owner-1', shares: [{ userId: USER_ID, role: 'EDITOR' }] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', USER_ID,
|
||||
vi.fn().mockResolvedValue(s),
|
||||
vi.fn()
|
||||
);
|
||||
expect(result).toMatchObject({ isOwner: false, role: 'EDITOR', canEdit: true });
|
||||
});
|
||||
|
||||
it('returns session with VIEWER role and canEdit=false for viewer share', async () => {
|
||||
const s = makeSession({ userId: 'owner-1', shares: [{ userId: USER_ID, role: 'VIEWER' }] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', USER_ID,
|
||||
vi.fn().mockResolvedValue(s),
|
||||
vi.fn()
|
||||
);
|
||||
expect(result).toMatchObject({ isOwner: false, role: 'VIEWER', canEdit: false });
|
||||
});
|
||||
|
||||
it('returns null when session not found anywhere', async () => {
|
||||
const result = await getSessionByIdGeneric(
|
||||
'missing', USER_ID,
|
||||
vi.fn().mockResolvedValue(null),
|
||||
vi.fn().mockResolvedValue(null)
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to admin check when no direct access', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const s = makeSession({ userId: 'owner-1', shares: [] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', 'admin-1',
|
||||
vi.fn().mockResolvedValue(null), // no direct access
|
||||
vi.fn().mockResolvedValue(s) // but found by id
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockIsAdminOfUser).toHaveBeenCalledWith('owner-1', 'admin-1');
|
||||
});
|
||||
|
||||
it('returns null when no direct access and not admin', async () => {
|
||||
const s = makeSession({ userId: 'owner-1', shares: [] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', 'stranger',
|
||||
vi.fn().mockResolvedValue(null),
|
||||
vi.fn().mockResolvedValue(s)
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('grants canEdit=true to team admin viewing member session', async () => {
|
||||
mockIsAdminOfUser.mockResolvedValue(true);
|
||||
const s = makeSession({ userId: 'owner-1', shares: [] });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', 'admin-1',
|
||||
vi.fn().mockResolvedValue(null),
|
||||
vi.fn().mockResolvedValue(s)
|
||||
);
|
||||
expect(result?.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it('applies resolveParticipant callback when provided', async () => {
|
||||
const s = makeSession({ userId: USER_ID, shares: [] });
|
||||
const resolveParticipant = vi.fn().mockResolvedValue({ participantName: 'Bob' });
|
||||
const result = await getSessionByIdGeneric(
|
||||
's1', USER_ID,
|
||||
vi.fn().mockResolvedValue(s),
|
||||
vi.fn(),
|
||||
resolveParticipant
|
||||
);
|
||||
expect(resolveParticipant).toHaveBeenCalledWith(expect.objectContaining({ id: 's1' }));
|
||||
expect((result as typeof result & { participantName: string })?.participantName).toBe('Bob');
|
||||
});
|
||||
});
|
||||
218
src/services/__tests__/session-share-events.test.ts
Normal file
218
src/services/__tests__/session-share-events.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createShareAndEventHandlers } from '@/services/session-share-events';
|
||||
|
||||
vi.mock('@/services/database', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { prisma } = await import('@/services/database');
|
||||
const mockFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
|
||||
// ── Mock delegate factories ────────────────────────────────────────────────
|
||||
|
||||
const OWNER_ID = 'owner-1';
|
||||
const SESSION_ID = 'session-1';
|
||||
|
||||
function makeSessionModel(session: object | null = { id: SESSION_ID, userId: OWNER_ID }) {
|
||||
return { findFirst: vi.fn().mockResolvedValue(session) };
|
||||
}
|
||||
|
||||
function makeShareModel() {
|
||||
return {
|
||||
upsert: vi.fn().mockResolvedValue({ id: 'share-1', userId: 'target-1', role: 'EDITOR' }),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEventModel(eventResult = { id: 'e1', sessionId: SESSION_ID, userId: OWNER_ID, type: 'UPDATE', payload: '{}', createdAt: new Date() }) {
|
||||
return {
|
||||
create: vi.fn().mockResolvedValue(eventResult),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
const canAccess = vi.fn().mockResolvedValue(true);
|
||||
|
||||
// ── share ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('share', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shares session with target user', async () => {
|
||||
const targetUser = { id: 'target-1', email: 'bob@example.com', name: 'Bob' };
|
||||
mockFindUnique.mockResolvedValue(targetUser);
|
||||
const shareModel = makeShareModel();
|
||||
const { share } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
|
||||
|
||||
await share(SESSION_ID, OWNER_ID, 'bob@example.com', 'EDITOR');
|
||||
|
||||
expect(shareModel.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { sessionId_userId: { sessionId: SESSION_ID, userId: 'target-1' } },
|
||||
create: expect.objectContaining({ sessionId: SESSION_ID, userId: 'target-1', role: 'EDITOR' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when target user is not found', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
const { share } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), canAccess);
|
||||
await expect(share(SESSION_ID, OWNER_ID, 'ghost@example.com')).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('throws when trying to share with yourself', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: OWNER_ID, email: 'owner@example.com', name: 'Owner' });
|
||||
const { share } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), canAccess);
|
||||
await expect(share(SESSION_ID, OWNER_ID, 'owner@example.com')).rejects.toThrow('Cannot share session with yourself');
|
||||
});
|
||||
|
||||
it('throws when session not owned by caller', async () => {
|
||||
mockFindUnique.mockResolvedValue({ id: 'target-1', email: 'bob@example.com', name: 'Bob' });
|
||||
const sessionModel = makeSessionModel(null); // findFirst returns null → not owned
|
||||
const { share } = createShareAndEventHandlers(sessionModel, makeShareModel(), makeEventModel(), canAccess);
|
||||
await expect(share(SESSION_ID, 'not-owner', 'bob@example.com')).rejects.toThrow('Session not found or not owned');
|
||||
});
|
||||
|
||||
it('defaults role to EDITOR', async () => {
|
||||
const targetUser = { id: 'target-1', email: 'bob@example.com', name: 'Bob' };
|
||||
mockFindUnique.mockResolvedValue(targetUser);
|
||||
const shareModel = makeShareModel();
|
||||
const { share } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
|
||||
|
||||
await share(SESSION_ID, OWNER_ID, 'bob@example.com');
|
||||
|
||||
expect(shareModel.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: expect.objectContaining({ role: 'EDITOR' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeShare ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeShare', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('removes share when caller is owner', async () => {
|
||||
const shareModel = makeShareModel();
|
||||
const { removeShare } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
|
||||
|
||||
await removeShare(SESSION_ID, OWNER_ID, 'target-1');
|
||||
|
||||
expect(shareModel.deleteMany).toHaveBeenCalledWith({ where: { sessionId: SESSION_ID, userId: 'target-1' } });
|
||||
});
|
||||
|
||||
it('throws when session not owned by caller', async () => {
|
||||
const { removeShare } = createShareAndEventHandlers(makeSessionModel(null), makeShareModel(), makeEventModel(), canAccess);
|
||||
await expect(removeShare(SESSION_ID, 'not-owner', 'target-1')).rejects.toThrow('Session not found or not owned');
|
||||
});
|
||||
});
|
||||
|
||||
// ── createEvent ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createEvent', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates event with correct data', async () => {
|
||||
const eventModel = makeEventModel();
|
||||
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, { key: 'value' });
|
||||
|
||||
expect(eventModel.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
sessionId: SESSION_ID,
|
||||
userId: OWNER_ID,
|
||||
type: 'UPDATE',
|
||||
payload: JSON.stringify({ key: 'value' }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers fire-and-forget cleanup after event creation', async () => {
|
||||
const eventModel = makeEventModel();
|
||||
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {});
|
||||
|
||||
// deleteMany is called as fire-and-forget (not awaited, but should be invoked)
|
||||
await vi.waitFor(() => expect(eventModel.deleteMany).toHaveBeenCalledTimes(1));
|
||||
expect(eventModel.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { createdAt: expect.objectContaining({ lt: expect.any(Date) }) } })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when cleanup fails', async () => {
|
||||
const eventModel = makeEventModel();
|
||||
eventModel.deleteMany.mockRejectedValue(new Error('DB error'));
|
||||
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
await expect(createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {})).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('returns the created event', async () => {
|
||||
const created = { id: 'e42', sessionId: SESSION_ID, userId: OWNER_ID, type: 'UPDATE', payload: '{}', createdAt: new Date() };
|
||||
const eventModel = makeEventModel(created);
|
||||
const { createEvent } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
const result = await createEvent(SESSION_ID, OWNER_ID, 'UPDATE' as never, {});
|
||||
expect(result.id).toBe('e42');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEvents ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEvents', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetches all events for a session without since filter', async () => {
|
||||
const eventModel = makeEventModel();
|
||||
const { getEvents } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
await getEvents(SESSION_ID);
|
||||
|
||||
expect(eventModel.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { sessionId: SESSION_ID } })
|
||||
);
|
||||
});
|
||||
|
||||
it('filters events by since timestamp when provided', async () => {
|
||||
const since = new Date('2024-01-01');
|
||||
const eventModel = makeEventModel();
|
||||
const { getEvents } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), eventModel, canAccess);
|
||||
|
||||
await getEvents(SESSION_ID, since);
|
||||
|
||||
expect(eventModel.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { sessionId: SESSION_ID, createdAt: { gt: since } } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getShares ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getShares', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns shares when user has access', async () => {
|
||||
const shares = [{ id: 'sh-1', user: { id: 'u1', name: 'Bob', email: 'bob@ex.com' } }];
|
||||
const shareModel = makeShareModel();
|
||||
shareModel.findMany.mockResolvedValue(shares);
|
||||
const { getShares } = createShareAndEventHandlers(makeSessionModel(), shareModel, makeEventModel(), canAccess);
|
||||
|
||||
const result = await getShares(SESSION_ID, OWNER_ID);
|
||||
expect(result).toEqual(shares);
|
||||
});
|
||||
|
||||
it('throws Access denied when user has no access', async () => {
|
||||
const noAccess = vi.fn().mockResolvedValue(false);
|
||||
const { getShares } = createShareAndEventHandlers(makeSessionModel(), makeShareModel(), makeEventModel(), noAccess);
|
||||
await expect(getShares(SESSION_ID, 'stranger')).rejects.toThrow('Access denied');
|
||||
});
|
||||
});
|
||||
244
src/services/__tests__/teams.test.ts
Normal file
244
src/services/__tests__/teams.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Make React cache a transparent pass-through in tests
|
||||
vi.mock('react', () => ({
|
||||
cache: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/database', () => {
|
||||
const mockTx = {
|
||||
team: { create: vi.fn() },
|
||||
teamMember: { create: vi.fn() },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
team: { create: vi.fn(), update: vi.fn() },
|
||||
teamMember: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => unknown) => fn(mockTx)),
|
||||
_mockTx: mockTx,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createTeam, addTeamMember, isAdminOfUser, getTeamMemberIdsForAdminTeams, getUserTeams } from '@/services/teams';
|
||||
|
||||
const { prisma } = await import('@/services/database');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockTx = (prisma as any)._mockTx;
|
||||
|
||||
// ── createTeam ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createTeam', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates team and adds creator as ADMIN in a transaction', async () => {
|
||||
const team = { id: 'team-1', name: 'Alpha', description: null, createdById: 'user-1' };
|
||||
mockTx.team.create.mockResolvedValueOnce(team);
|
||||
mockTx.teamMember.create.mockResolvedValueOnce({});
|
||||
|
||||
const result = await createTeam('Alpha', null, 'user-1');
|
||||
|
||||
expect(vi.mocked(prisma.$transaction)).toHaveBeenCalledTimes(1);
|
||||
expect(mockTx.team.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: 'Alpha', description: null, createdById: 'user-1' } })
|
||||
);
|
||||
expect(mockTx.teamMember.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { teamId: 'team-1', userId: 'user-1', role: 'ADMIN' } })
|
||||
);
|
||||
expect(result).toEqual(team);
|
||||
});
|
||||
|
||||
it('returns the created team (not the member)', async () => {
|
||||
const team = { id: 'team-2', name: 'Beta', description: 'desc', createdById: 'user-2' };
|
||||
mockTx.team.create.mockResolvedValueOnce(team);
|
||||
mockTx.teamMember.create.mockResolvedValueOnce({ id: 'member-1' });
|
||||
|
||||
const result = await createTeam('Beta', 'desc', 'user-2');
|
||||
expect(result).toEqual(team);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addTeamMember ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('addTeamMember', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('throws when user is already a member', async () => {
|
||||
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce({
|
||||
teamId: 'team-1', userId: 'user-1', role: 'MEMBER',
|
||||
} as never);
|
||||
|
||||
await expect(addTeamMember('team-1', 'user-1')).rejects.toThrow('déjà membre');
|
||||
expect(vi.mocked(prisma.teamMember.create)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates member with MEMBER role by default', async () => {
|
||||
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.teamMember.create).mockResolvedValueOnce({
|
||||
userId: 'user-2', teamId: 'team-1', role: 'MEMBER',
|
||||
} as never);
|
||||
|
||||
await addTeamMember('team-1', 'user-2');
|
||||
|
||||
expect(vi.mocked(prisma.teamMember.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { teamId: 'team-1', userId: 'user-2', role: 'MEMBER' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates member with specified role', async () => {
|
||||
vi.mocked(prisma.teamMember.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.teamMember.create).mockResolvedValueOnce({} as never);
|
||||
|
||||
await addTeamMember('team-1', 'user-3', 'ADMIN');
|
||||
|
||||
expect(vi.mocked(prisma.teamMember.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ role: 'ADMIN' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getTeamMemberIdsForAdminTeams ──────────────────────────────────────────
|
||||
|
||||
describe('getTeamMemberIdsForAdminTeams', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns empty array when user is not admin of any team', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTeamMemberIdsForAdminTeams('user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
// Should not query members when no admin teams
|
||||
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns deduplicated member IDs across admin teams', async () => {
|
||||
// First call: get admin teams
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([{ teamId: 'team-1' }, { teamId: 'team-2' }] as never)
|
||||
// Second call: get members of those teams
|
||||
.mockResolvedValueOnce([{ userId: 'user-2' }, { userId: 'user-3' }] as never);
|
||||
|
||||
const result = await getTeamMemberIdsForAdminTeams('user-1');
|
||||
|
||||
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual(['user-2', 'user-3']);
|
||||
});
|
||||
|
||||
it('queries members excluding the admin user themselves', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never)
|
||||
.mockResolvedValueOnce([{ userId: 'user-2' }] as never);
|
||||
|
||||
await getTeamMemberIdsForAdminTeams('admin-user');
|
||||
|
||||
expect(vi.mocked(prisma.teamMember.findMany)).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ where: expect.objectContaining({ userId: { not: 'admin-user' } }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isAdminOfUser ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isAdminOfUser', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns false when ownerUserId equals adminUserId', async () => {
|
||||
const result = await isAdminOfUser('same-user', 'same-user');
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(prisma.teamMember.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when adminUser is admin of a team containing ownerUser', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never) // admin teams
|
||||
.mockResolvedValueOnce([{ userId: 'owner-user' }] as never); // members
|
||||
|
||||
const result = await isAdminOfUser('owner-user', 'admin-user');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when adminUser is not in same team as ownerUser', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([{ teamId: 'team-1' }] as never)
|
||||
.mockResolvedValueOnce([{ userId: 'other-user' }] as never); // different members
|
||||
|
||||
const result = await isAdminOfUser('owner-user', 'admin-user');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when admin has no admin teams', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await isAdminOfUser('owner-user', 'admin-user');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserTeams ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUserTeams', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('transforms result to include userRole and userOkrCount', async () => {
|
||||
const mockMembership = {
|
||||
role: 'ADMIN',
|
||||
_count: { okrs: 3 },
|
||||
team: {
|
||||
id: 'team-1',
|
||||
name: 'Alpha',
|
||||
description: null,
|
||||
members: [],
|
||||
_count: { members: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([mockMembership] as never);
|
||||
|
||||
const result = await getUserTeams('user-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'team-1',
|
||||
name: 'Alpha',
|
||||
userRole: 'ADMIN',
|
||||
userOkrCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when user is in no teams', async () => {
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
|
||||
const result = await getUserTeams('user-1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('spreads team properties into the result', async () => {
|
||||
const mockMembership = {
|
||||
role: 'MEMBER',
|
||||
_count: { okrs: 0 },
|
||||
team: {
|
||||
id: 'team-2',
|
||||
name: 'Beta',
|
||||
description: 'A team',
|
||||
members: [{ id: 'm-1' }],
|
||||
_count: { members: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([mockMembership] as never);
|
||||
|
||||
const result = await getUserTeams('user-2');
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'team-2',
|
||||
description: 'A team',
|
||||
userRole: 'MEMBER',
|
||||
userOkrCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
279
src/services/__tests__/weather.test.ts
Normal file
279
src/services/__tests__/weather.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getPreviousWeatherEntriesForUsers,
|
||||
shareWeatherSessionToTeam,
|
||||
getWeatherSessionsHistory,
|
||||
} from '@/services/weather';
|
||||
|
||||
vi.mock('@/services/database', () => ({
|
||||
prisma: {
|
||||
weatherEntry: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
weatherSession: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
weatherSessionShare: {
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
teamMember: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// teams service is imported by weather.ts
|
||||
vi.mock('@/services/teams', () => ({
|
||||
getTeamMemberIdsForAdminTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
// session-permissions and session-share-events are module-level side effects
|
||||
vi.mock('@/services/session-permissions', () => ({
|
||||
createSessionPermissionChecks: () => ({
|
||||
canAccess: vi.fn().mockResolvedValue(true),
|
||||
canEdit: vi.fn().mockResolvedValue(true),
|
||||
canDelete: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/session-share-events', () => ({
|
||||
createShareAndEventHandlers: () => ({
|
||||
share: vi.fn(),
|
||||
removeShare: vi.fn(),
|
||||
getShares: vi.fn(),
|
||||
createEvent: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getLatestEventTimestamp: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/session-queries', () => ({
|
||||
mergeSessionsByUserId: vi.fn(),
|
||||
fetchTeamCollaboratorSessions: vi.fn(),
|
||||
getSessionByIdGeneric: vi.fn(),
|
||||
}));
|
||||
|
||||
const { prisma } = await import('@/services/database');
|
||||
|
||||
// ── getPreviousWeatherEntriesForUsers ──────────────────────────────────────
|
||||
|
||||
describe('getPreviousWeatherEntriesForUsers', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns empty map when userIds is empty', async () => {
|
||||
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), []);
|
||||
expect(result.size).toBe(0);
|
||||
expect(vi.mocked(prisma.weatherEntry.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns one entry per user using most recent values', async () => {
|
||||
const olderDate = new Date('2026-01-01');
|
||||
const newerDate = new Date('2026-01-08');
|
||||
|
||||
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
|
||||
{ userId: 'u1', performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date: newerDate } },
|
||||
{ userId: 'u1', performanceEmoji: '🌧️', moralEmoji: '☀️', fluxEmoji: null, valueCreationEmoji: null, session: { date: olderDate } },
|
||||
] as never);
|
||||
|
||||
const result = await getPreviousWeatherEntriesForUsers('current-session', new Date(), ['u1']);
|
||||
|
||||
const entry = result.get('u1');
|
||||
// Most recent entry (newerDate) wins for performanceEmoji
|
||||
expect(entry?.performanceEmoji).toBe('☀️');
|
||||
// Falls back to older entry for moralEmoji (newer was null)
|
||||
expect(entry?.moralEmoji).toBe('☀️');
|
||||
});
|
||||
|
||||
it('uses per-axis fallback when latest entry has null values', async () => {
|
||||
const older = new Date('2026-01-01');
|
||||
const newer = new Date('2026-01-08');
|
||||
|
||||
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
|
||||
// Newer: has performance but not moral
|
||||
{ userId: 'u1', performanceEmoji: '☁️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date: newer } },
|
||||
// Older: has moral but not performance
|
||||
{ userId: 'u1', performanceEmoji: null, moralEmoji: '🌤️', fluxEmoji: null, valueCreationEmoji: null, session: { date: older } },
|
||||
] as never);
|
||||
|
||||
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1']);
|
||||
const entry = result.get('u1');
|
||||
|
||||
expect(entry?.performanceEmoji).toBe('☁️'); // from newer
|
||||
expect(entry?.moralEmoji).toBe('🌤️'); // fallback from older
|
||||
});
|
||||
|
||||
it('handles multiple users independently', async () => {
|
||||
const date = new Date('2026-01-01');
|
||||
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([
|
||||
{ userId: 'u1', performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date } },
|
||||
{ userId: 'u2', performanceEmoji: '🌧️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null, session: { date } },
|
||||
] as never);
|
||||
|
||||
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1', 'u2']);
|
||||
|
||||
expect(result.get('u1')?.performanceEmoji).toBe('☀️');
|
||||
expect(result.get('u2')?.performanceEmoji).toBe('🌧️');
|
||||
});
|
||||
|
||||
it('returns null for all axes when no previous entries for a user', async () => {
|
||||
vi.mocked(prisma.weatherEntry.findMany).mockResolvedValueOnce([]);
|
||||
const result = await getPreviousWeatherEntriesForUsers('s-1', new Date(), ['u1']);
|
||||
// No entries returned, map is empty
|
||||
expect(result.get('u1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── shareWeatherSessionToTeam ──────────────────────────────────────────────
|
||||
|
||||
describe('shareWeatherSessionToTeam', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('throws when session not found or not owned', async () => {
|
||||
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
|
||||
'Session not found or not owned'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when another weather session exists for same team this week', async () => {
|
||||
const sessionDate = new Date('2026-03-10');
|
||||
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([{ userId: 'u2' }, { userId: 'u3' }] as never); // count check
|
||||
vi.mocked(prisma.weatherSession.count).mockResolvedValueOnce(1); // existing session this week
|
||||
|
||||
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
|
||||
'déjà une météo pour cette semaine'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when team has no members', async () => {
|
||||
const sessionDate = new Date('2026-03-10');
|
||||
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([]) // no members for count check → skip week check
|
||||
.mockResolvedValueOnce([]); // no full members either
|
||||
|
||||
await expect(shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
|
||||
'Team has no members'
|
||||
);
|
||||
});
|
||||
|
||||
it('shares session with all team members except owner', async () => {
|
||||
const sessionDate = new Date('2026-03-10');
|
||||
vi.mocked(prisma.weatherSession.findFirst).mockResolvedValueOnce({ id: 's-1', date: sessionDate } as never);
|
||||
// First findMany: team member IDs for week check (empty → skips count check)
|
||||
vi.mocked(prisma.teamMember.findMany)
|
||||
.mockResolvedValueOnce([]) // empty for week-check path
|
||||
.mockResolvedValueOnce([
|
||||
{ userId: 'owner-1', user: { id: 'owner-1', name: 'Owner', email: 'o@ex.com' } },
|
||||
{ userId: 'member-1', user: { id: 'member-1', name: 'Member', email: 'm@ex.com' } },
|
||||
{ userId: 'member-2', user: { id: 'member-2', name: 'Member2', email: 'm2@ex.com' } },
|
||||
] as never); // full members
|
||||
vi.mocked(prisma.weatherSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
|
||||
|
||||
await shareWeatherSessionToTeam('s-1', 'owner-1', 'team-1');
|
||||
|
||||
// Should call upsert for member-1 and member-2 (not owner-1)
|
||||
expect(vi.mocked(prisma.weatherSessionShare.upsert)).toHaveBeenCalledTimes(2);
|
||||
const calls = vi.mocked(prisma.weatherSessionShare.upsert).mock.calls;
|
||||
const sharedUserIds = calls.map((c) => c[0].create.userId);
|
||||
expect(sharedUserIds).toContain('member-1');
|
||||
expect(sharedUserIds).toContain('member-2');
|
||||
expect(sharedUserIds).not.toContain('owner-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getWeatherSessionsHistory ──────────────────────────────────────────────
|
||||
|
||||
describe('getWeatherSessionsHistory', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns sorted history by date ascending', async () => {
|
||||
const older = new Date('2026-01-01');
|
||||
const newer = new Date('2026-02-01');
|
||||
|
||||
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
|
||||
{ id: 's-2', title: 'Session 2', date: newer, entries: [] },
|
||||
{ id: 's-1', title: 'Session 1', date: older, entries: [] },
|
||||
] as never);
|
||||
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWeatherSessionsHistory('user-1');
|
||||
|
||||
expect(result[0].sessionId).toBe('s-1');
|
||||
expect(result[1].sessionId).toBe('s-2');
|
||||
});
|
||||
|
||||
it('deduplicates sessions appearing in both own and shared', async () => {
|
||||
const date = new Date('2026-01-01');
|
||||
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
|
||||
{ id: 's-1', title: 'Session', date, entries: [] },
|
||||
] as never);
|
||||
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([
|
||||
{ session: { id: 's-1', title: 'Session', date, entries: [] } },
|
||||
] as never);
|
||||
|
||||
const result = await getWeatherSessionsHistory('user-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calculates average scores from entries using emoji → number mapping', async () => {
|
||||
const date = new Date('2026-01-01');
|
||||
// '☀️' is index 1 in WEATHER_EMOJIS, '🌤️' is index 2
|
||||
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: 's-1',
|
||||
title: 'Session',
|
||||
date,
|
||||
entries: [
|
||||
{ performanceEmoji: '☀️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null },
|
||||
{ performanceEmoji: '🌤️', moralEmoji: null, fluxEmoji: null, valueCreationEmoji: null },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWeatherSessionsHistory('user-1');
|
||||
|
||||
// avgScore(['☀️', '🌤️']) = (1 + 2) / 2 = 1.5
|
||||
expect(result[0].performance).toBe(1.5);
|
||||
expect(result[0].moral).toBeNull(); // all null → null
|
||||
});
|
||||
|
||||
it('returns null score when no entries have that axis', async () => {
|
||||
const date = new Date('2026-01-01');
|
||||
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
|
||||
{ id: 's-1', title: 'Session', date, entries: [] },
|
||||
] as never);
|
||||
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWeatherSessionsHistory('user-1');
|
||||
|
||||
expect(result[0].performance).toBeNull();
|
||||
expect(result[0].moral).toBeNull();
|
||||
expect(result[0].flux).toBeNull();
|
||||
expect(result[0].valueCreation).toBeNull();
|
||||
});
|
||||
|
||||
it('includes both own and shared sessions', async () => {
|
||||
vi.mocked(prisma.weatherSession.findMany).mockResolvedValueOnce([
|
||||
{ id: 's-own', title: 'Own', date: new Date('2026-01-01'), entries: [] },
|
||||
] as never);
|
||||
vi.mocked(prisma.weatherSessionShare.findMany).mockResolvedValueOnce([
|
||||
{ session: { id: 's-shared', title: 'Shared', date: new Date('2026-02-01'), entries: [] } },
|
||||
] as never);
|
||||
|
||||
const result = await getWeatherSessionsHistory('user-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const ids = result.map((r) => r.sessionId);
|
||||
expect(ids).toContain('s-own');
|
||||
expect(ids).toContain('s-shared');
|
||||
});
|
||||
});
|
||||
511
src/services/__tests__/workshops.test.ts
Normal file
511
src/services/__tests__/workshops.test.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Workshop-specific business logic tests:
|
||||
* - sessions.ts: createSwotItem, duplicateSwotItem, updateAction
|
||||
* - moving-motivators.ts: createMotivatorSession, updateCardInfluence
|
||||
* - gif-mood.ts: addGifMoodItem, shareGifMoodSessionToTeam
|
||||
* - session-share-events.ts: getLatestEventTimestamp, cleanupOldEvents
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Shared mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/services/database', () => ({
|
||||
prisma: {
|
||||
swotItem: {
|
||||
aggregate: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
actionLink: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
action: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
movingMotivatorsSession: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
motivatorCard: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
gifMoodItem: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
gifMoodSession: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
gMSessionShare: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
teamMember: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
resolveCollaborator: vi.fn(),
|
||||
batchResolveCollaborators: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/teams', () => ({
|
||||
getTeamMemberIdsForAdminTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/session-permissions', () => ({
|
||||
createSessionPermissionChecks: () => ({
|
||||
canAccess: vi.fn().mockResolvedValue(true),
|
||||
canEdit: vi.fn().mockResolvedValue(true),
|
||||
canDelete: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/session-share-events', () => ({
|
||||
createShareAndEventHandlers: () => ({
|
||||
share: vi.fn(),
|
||||
removeShare: vi.fn(),
|
||||
getShares: vi.fn(),
|
||||
createEvent: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getLatestEventTimestamp: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/session-queries', () => ({
|
||||
mergeSessionsByUserId: vi.fn().mockResolvedValue([]),
|
||||
fetchTeamCollaboratorSessions: vi.fn().mockResolvedValue([]),
|
||||
getSessionByIdGeneric: vi.fn(),
|
||||
}));
|
||||
|
||||
const { prisma } = await import('@/services/database');
|
||||
|
||||
// ── sessions.ts: createSwotItem ──────────────────────────────────────────
|
||||
|
||||
import { createSwotItem, duplicateSwotItem, updateAction } from '@/services/sessions';
|
||||
|
||||
describe('createSwotItem', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('uses maxOrder + 1 for the new item order', async () => {
|
||||
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: 3 } } as never);
|
||||
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-1', order: 4 } as never);
|
||||
|
||||
await createSwotItem('session-1', { content: 'New item', category: 'STRENGTH' });
|
||||
|
||||
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ order: 4 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses order 0 when no items exist yet', async () => {
|
||||
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
|
||||
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-1', order: 0 } as never);
|
||||
|
||||
await createSwotItem('session-1', { content: 'First item', category: 'WEAKNESS' });
|
||||
|
||||
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ order: 0 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('queries aggregate for the correct session and category', async () => {
|
||||
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
|
||||
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({} as never);
|
||||
|
||||
await createSwotItem('session-42', { content: 'Item', category: 'OPPORTUNITY' });
|
||||
|
||||
expect(vi.mocked(prisma.swotItem.aggregate)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { sessionId: 'session-42', category: 'OPPORTUNITY' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sessions.ts: duplicateSwotItem ────────────────────────────────────────
|
||||
|
||||
describe('duplicateSwotItem', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('throws when original item not found', async () => {
|
||||
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(duplicateSwotItem('item-999')).rejects.toThrow('Item not found');
|
||||
});
|
||||
|
||||
it('creates copy with recalculated order (maxOrder + 1)', async () => {
|
||||
const original = { id: 'item-1', content: 'Original', category: 'STRENGTH', sessionId: 'session-1' };
|
||||
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(original as never);
|
||||
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: 5 } } as never);
|
||||
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-2', order: 6 } as never);
|
||||
|
||||
await duplicateSwotItem('item-1');
|
||||
|
||||
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
content: 'Original',
|
||||
category: 'STRENGTH',
|
||||
sessionId: 'session-1',
|
||||
order: 6,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses order 0 when category has no existing items', async () => {
|
||||
const original = { id: 'item-1', content: 'Item', category: 'THREAT', sessionId: 's-1' };
|
||||
vi.mocked(prisma.swotItem.findUnique).mockResolvedValueOnce(original as never);
|
||||
vi.mocked(prisma.swotItem.aggregate).mockResolvedValueOnce({ _max: { order: null } } as never);
|
||||
vi.mocked(prisma.swotItem.create).mockResolvedValueOnce({ id: 'item-2', order: 0 } as never);
|
||||
|
||||
await duplicateSwotItem('item-1');
|
||||
|
||||
expect(vi.mocked(prisma.swotItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ order: 0 }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sessions.ts: updateAction ─────────────────────────────────────────────
|
||||
|
||||
describe('updateAction', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('does not touch links when linkedItemIds is not provided', async () => {
|
||||
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
|
||||
|
||||
await updateAction('a-1', { title: 'Updated title' });
|
||||
|
||||
expect(vi.mocked(prisma.actionLink.deleteMany)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.actionLink.createMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes all existing links and recreates when linkedItemIds is provided', async () => {
|
||||
vi.mocked(prisma.actionLink.deleteMany).mockResolvedValueOnce({ count: 2 } as never);
|
||||
vi.mocked(prisma.actionLink.createMany).mockResolvedValueOnce({ count: 2 } as never);
|
||||
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
|
||||
|
||||
await updateAction('a-1', { linkedItemIds: ['item-1', 'item-2'] });
|
||||
|
||||
expect(vi.mocked(prisma.actionLink.deleteMany)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { actionId: 'a-1' } })
|
||||
);
|
||||
expect(vi.mocked(prisma.actionLink.createMany)).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ actionId: 'a-1', swotItemId: 'item-1' },
|
||||
{ actionId: 'a-1', swotItemId: 'item-2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes links but skips createMany when linkedItemIds is empty', async () => {
|
||||
vi.mocked(prisma.actionLink.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||
vi.mocked(prisma.action.update).mockResolvedValueOnce({ id: 'a-1', links: [] } as never);
|
||||
|
||||
await updateAction('a-1', { linkedItemIds: [] });
|
||||
|
||||
expect(vi.mocked(prisma.actionLink.deleteMany)).toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.actionLink.createMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── moving-motivators.ts: createMotivatorSession ──────────────────────────
|
||||
|
||||
import { createMotivatorSession, updateCardInfluence } from '@/services/moving-motivators';
|
||||
|
||||
describe('createMotivatorSession', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates session with exactly 10 motivator cards', async () => {
|
||||
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({
|
||||
id: 'session-1',
|
||||
cards: new Array(10).fill({ id: 'c', type: 'STATUS', orderIndex: 1, influence: 0 }),
|
||||
} as never);
|
||||
|
||||
await createMotivatorSession('user-1', { title: 'Test', participant: 'Alice' });
|
||||
|
||||
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
|
||||
expect(call.data.cards.create).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('initializes all cards with influence 0 and correct orderIndex (1-based)', async () => {
|
||||
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({ id: 's-1', cards: [] } as never);
|
||||
|
||||
await createMotivatorSession('user-1', { title: 'Test', participant: 'Alice' });
|
||||
|
||||
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
|
||||
const cards = call.data.cards.create;
|
||||
|
||||
expect(cards[0]).toMatchObject({ influence: 0, orderIndex: 1 });
|
||||
expect(cards[9]).toMatchObject({ influence: 0, orderIndex: 10 });
|
||||
});
|
||||
|
||||
it('includes all 10 motivator types', async () => {
|
||||
vi.mocked(prisma.movingMotivatorsSession.create).mockResolvedValueOnce({ id: 's-1', cards: [] } as never);
|
||||
|
||||
await createMotivatorSession('user-1', { title: 'T', participant: 'B' });
|
||||
|
||||
const call = vi.mocked(prisma.movingMotivatorsSession.create).mock.calls[0][0];
|
||||
const types = call.data.cards.create.map((c: { type: string }) => c.type);
|
||||
|
||||
expect(types).toContain('STATUS');
|
||||
expect(types).toContain('PURPOSE');
|
||||
expect(types).toContain('CURIOSITY');
|
||||
expect(types).toContain('FREEDOM');
|
||||
expect(new Set(types).size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── moving-motivators.ts: updateCardInfluence ─────────────────────────────
|
||||
|
||||
describe('updateCardInfluence', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('passes influence value as-is when within bounds', async () => {
|
||||
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: 2 } as never);
|
||||
|
||||
await updateCardInfluence('c-1', 2);
|
||||
|
||||
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { influence: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps influence to -3 when below minimum', async () => {
|
||||
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: -3 } as never);
|
||||
|
||||
await updateCardInfluence('c-1', -10);
|
||||
|
||||
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { influence: -3 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps influence to +3 when above maximum', async () => {
|
||||
vi.mocked(prisma.motivatorCard.update).mockResolvedValueOnce({ id: 'c-1', influence: 3 } as never);
|
||||
|
||||
await updateCardInfluence('c-1', 99);
|
||||
|
||||
expect(vi.mocked(prisma.motivatorCard.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { influence: 3 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('allows exact boundary values -3 and +3', async () => {
|
||||
vi.mocked(prisma.motivatorCard.update).mockResolvedValue({ id: 'c-1', influence: -3 } as never);
|
||||
|
||||
await updateCardInfluence('c-1', -3);
|
||||
await updateCardInfluence('c-1', 3);
|
||||
|
||||
const calls = vi.mocked(prisma.motivatorCard.update).mock.calls;
|
||||
expect(calls[0][0].data.influence).toBe(-3);
|
||||
expect(calls[1][0].data.influence).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── gif-mood.ts: addGifMoodItem ────────────────────────────────────────────
|
||||
|
||||
import { addGifMoodItem, shareGifMoodSessionToTeam } from '@/services/gif-mood';
|
||||
|
||||
describe('addGifMoodItem', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('throws when user has reached GIF_MOOD_MAX_ITEMS limit (5)', async () => {
|
||||
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(5);
|
||||
|
||||
await expect(addGifMoodItem('session-1', 'user-1', { gifUrl: 'https://example.com/gif.gif' }))
|
||||
.rejects.toThrow('Maximum 5');
|
||||
});
|
||||
|
||||
it('creates item with order set to current count (append at end)', async () => {
|
||||
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(2);
|
||||
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1', order: 2 } as never);
|
||||
|
||||
await addGifMoodItem('session-1', 'user-1', { gifUrl: 'https://example.com/gif.gif' });
|
||||
|
||||
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ order: 2 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('allows adding when count is below limit', async () => {
|
||||
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(4);
|
||||
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1' } as never);
|
||||
|
||||
await expect(addGifMoodItem('s-1', 'u-1', { gifUrl: 'url', note: 'hello' })).resolves.not.toThrow();
|
||||
|
||||
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ note: 'hello' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets note to null when not provided', async () => {
|
||||
vi.mocked(prisma.gifMoodItem.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.gifMoodItem.create).mockResolvedValueOnce({ id: 'gif-1' } as never);
|
||||
|
||||
await addGifMoodItem('s-1', 'u-1', { gifUrl: 'url' });
|
||||
|
||||
expect(vi.mocked(prisma.gifMoodItem.create)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ note: null }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── gif-mood.ts: shareGifMoodSessionToTeam ────────────────────────────────
|
||||
|
||||
describe('shareGifMoodSessionToTeam', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('throws when session not found or not owned', async () => {
|
||||
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
|
||||
'Session not found or not owned'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when team has no members', async () => {
|
||||
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
await expect(shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1')).rejects.toThrow(
|
||||
'Team has no members'
|
||||
);
|
||||
});
|
||||
|
||||
it('shares with all team members except owner', async () => {
|
||||
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([
|
||||
{ userId: 'owner-1', user: { id: 'owner-1', name: 'Owner', email: 'o@ex.com' } },
|
||||
{ userId: 'member-1', user: { id: 'member-1', name: 'Member', email: 'm@ex.com' } },
|
||||
] as never);
|
||||
vi.mocked(prisma.gMSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
|
||||
|
||||
await shareGifMoodSessionToTeam('s-1', 'owner-1', 'team-1');
|
||||
|
||||
// Only member-1 gets shared (not owner-1)
|
||||
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: expect.objectContaining({ userId: 'member-1' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses EDITOR role by default', async () => {
|
||||
vi.mocked(prisma.gifMoodSession.findFirst).mockResolvedValueOnce({ id: 's-1' } as never);
|
||||
vi.mocked(prisma.teamMember.findMany).mockResolvedValueOnce([
|
||||
{ userId: 'member-1', user: { id: 'member-1', name: 'M', email: 'm@ex.com' } },
|
||||
] as never);
|
||||
vi.mocked(prisma.gMSessionShare.upsert).mockResolvedValue({ role: 'EDITOR', user: {} } as never);
|
||||
|
||||
await shareGifMoodSessionToTeam('s-1', 'different-owner', 'team-1');
|
||||
|
||||
expect(vi.mocked(prisma.gMSessionShare.upsert)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: expect.objectContaining({ role: 'EDITOR' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── session-share-events.ts: getLatestEventTimestamp & cleanupOldEvents ──────
|
||||
|
||||
// Use the real implementation (not the mock above which is for internal service use)
|
||||
const { createShareAndEventHandlers } = await vi.importActual<
|
||||
typeof import('@/services/session-share-events')
|
||||
>('@/services/session-share-events');
|
||||
|
||||
describe('getLatestEventTimestamp', () => {
|
||||
it('returns the createdAt of the most recent event', async () => {
|
||||
const ts = new Date('2026-03-10T10:00:00Z');
|
||||
const eventModel = {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn().mockResolvedValueOnce({ createdAt: ts }),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
const { getLatestEventTimestamp } = createShareAndEventHandlers(
|
||||
{ findFirst: vi.fn() },
|
||||
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
|
||||
eventModel,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
const result = await getLatestEventTimestamp('session-1');
|
||||
expect(result).toEqual(ts);
|
||||
});
|
||||
|
||||
it('returns undefined when no events exist', async () => {
|
||||
const eventModel = {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn().mockResolvedValueOnce(null),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
const { getLatestEventTimestamp } = createShareAndEventHandlers(
|
||||
{ findFirst: vi.fn() },
|
||||
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
|
||||
eventModel,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
const result = await getLatestEventTimestamp('session-1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldEvents', () => {
|
||||
it('deletes events older than 24 hours by default', async () => {
|
||||
const deleteMany = vi.fn().mockResolvedValueOnce({ count: 5 });
|
||||
const eventModel = { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), deleteMany };
|
||||
const { cleanupOldEvents } = createShareAndEventHandlers(
|
||||
{ findFirst: vi.fn() },
|
||||
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
|
||||
eventModel,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
await cleanupOldEvents();
|
||||
const after = Date.now();
|
||||
|
||||
const cutoff: Date = deleteMany.mock.calls[0][0].where.createdAt.lt;
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - 24 * 60 * 60 * 1000 - 100);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(after - 24 * 60 * 60 * 1000 + 100);
|
||||
});
|
||||
|
||||
it('deletes events older than custom maxAgeHours', async () => {
|
||||
const deleteMany = vi.fn().mockResolvedValueOnce({ count: 0 });
|
||||
const eventModel = { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), deleteMany };
|
||||
const { cleanupOldEvents } = createShareAndEventHandlers(
|
||||
{ findFirst: vi.fn() },
|
||||
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
|
||||
eventModel,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
await cleanupOldEvents(1); // 1 hour
|
||||
const after = Date.now();
|
||||
|
||||
const cutoff: Date = deleteMany.mock.calls[0][0].where.createdAt.lt;
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - 60 * 60 * 1000 - 100);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(after - 60 * 60 * 1000 + 100);
|
||||
});
|
||||
|
||||
it('returns the count of deleted events', async () => {
|
||||
const eventModel = {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn().mockResolvedValueOnce({ count: 42 }),
|
||||
};
|
||||
const { cleanupOldEvents } = createShareAndEventHandlers(
|
||||
{ findFirst: vi.fn() },
|
||||
{ upsert: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
|
||||
eventModel,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
const result = await cleanupOldEvents();
|
||||
expect(result).toEqual({ count: 42 });
|
||||
});
|
||||
});
|
||||
23
vitest.config.ts
Normal file
23
vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/services/**/*.ts', 'src/lib/**/*.ts'],
|
||||
exclude: [
|
||||
'src/services/__tests__/**',
|
||||
'src/lib/__tests__/**',
|
||||
'src/services/database.ts',
|
||||
'src/lib/types.ts',
|
||||
'src/lib/auth.ts',
|
||||
'src/lib/auth.config.ts',
|
||||
],
|
||||
reporter: ['text', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user