Add unit tests for lib utilities (P3.2-P3.6)
Implement comprehensive test coverage for five library modules: - encryption.test.ts (14 tests): AES-256-GCM encrypt/decrypt round-trip, ciphertext format validation, error handling, key padding/truncation - nutrition.test.ts (17 tests): seed cycling by cycle day, carb ranges by phase, keto guidance by phase, seed switch alert on day 15 - garmin.test.ts (14 tests): token expiry checks, days until expiry calculation, API fetch with auth headers, error handling - email.test.ts (14 tests): daily email composition with biometrics, nutrition guidance, period confirmation emails, null value handling - ics.test.ts (23 tests): ICS format validation, phase events with emojis, warning events on days 22/25, cycle length handling Total: 82 new tests bringing project total to 254 passing tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
191
src/lib/email.test.ts
Normal file
191
src/lib/email.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// ABOUTME: Unit tests for email sending utilities.
|
||||
// ABOUTME: Tests email composition, subject lines, and Resend integration.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSend } = vi.hoisted(() => ({
|
||||
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||
}));
|
||||
|
||||
// Mock the resend module before importing email utilities
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class MockResend {
|
||||
emails = { send: mockSend };
|
||||
},
|
||||
}));
|
||||
|
||||
import type { DailyEmailData } from "./email";
|
||||
import { sendDailyEmail, sendPeriodConfirmationEmail } from "./email";
|
||||
|
||||
describe("sendDailyEmail", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const sampleData: DailyEmailData = {
|
||||
to: "user@example.com",
|
||||
cycleDay: 15,
|
||||
phase: "OVULATION",
|
||||
decision: {
|
||||
status: "TRAIN",
|
||||
reason: "Body battery high, HRV balanced - great day for training!",
|
||||
icon: "💪",
|
||||
},
|
||||
bodyBatteryCurrent: 85,
|
||||
bodyBatteryYesterdayLow: 45,
|
||||
hrvStatus: "Balanced",
|
||||
weekIntensity: 60,
|
||||
phaseLimit: 80,
|
||||
remainingMinutes: 20,
|
||||
seeds: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
carbRange: "100-150g",
|
||||
ketoGuidance: "No - exit keto, need carbs for ovulation",
|
||||
};
|
||||
|
||||
it("sends email with correct subject line", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: "Today's Training: 💪 TRAIN",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes cycle day and phase in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
||||
});
|
||||
|
||||
it("includes decision icon and reason", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain(
|
||||
"💪 Body battery high, HRV balanced - great day for training!",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes biometric data in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("Body Battery Now: 85");
|
||||
expect(call.text).toContain("Yesterday's Low: 45");
|
||||
expect(call.text).toContain("HRV Status: Balanced");
|
||||
expect(call.text).toContain("Week Intensity: 60 / 80 minutes");
|
||||
expect(call.text).toContain("Remaining: 20 minutes");
|
||||
});
|
||||
|
||||
it("includes nutrition guidance in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain(
|
||||
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
);
|
||||
expect(call.text).toContain("🍽️ MACROS: 100-150g");
|
||||
expect(call.text).toContain(
|
||||
"🥑 KETO: No - exit keto, need carbs for ovulation",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles null body battery values with N/A", async () => {
|
||||
const dataWithNulls: DailyEmailData = {
|
||||
...sampleData,
|
||||
bodyBatteryCurrent: null,
|
||||
bodyBatteryYesterdayLow: null,
|
||||
};
|
||||
await sendDailyEmail(dataWithNulls);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("Body Battery Now: N/A");
|
||||
expect(call.text).toContain("Yesterday's Low: N/A");
|
||||
});
|
||||
|
||||
it("sends email to correct recipient", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPeriodConfirmationEmail", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends email with correct subject", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"user@example.com",
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: "🔵 Period Tracking Updated",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes last period date in body", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"user@example.com",
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
// Date formatting depends on locale, so check for key parts
|
||||
expect(call.text).toContain("Your cycle has been reset");
|
||||
expect(call.text).toContain("Last period:");
|
||||
});
|
||||
|
||||
it("includes cycle length in body", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"user@example.com",
|
||||
new Date("2025-01-15"),
|
||||
28,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
||||
});
|
||||
|
||||
it("sends to correct recipient", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"test@example.com",
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"user@example.com",
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
|
||||
it("mentions calendar update timing", async () => {
|
||||
await sendPeriodConfirmationEmail(
|
||||
"user@example.com",
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
expect(call.text).toContain(
|
||||
"Your calendar will update automatically within 24 hours",
|
||||
);
|
||||
});
|
||||
});
|
||||
138
src/lib/encryption.test.ts
Normal file
138
src/lib/encryption.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// ABOUTME: Unit tests for AES-256-GCM encryption utilities.
|
||||
// ABOUTME: Tests encrypt/decrypt round-trip, error handling, and security properties.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { decrypt, encrypt } from "./encryption";
|
||||
|
||||
describe("encryption", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.ENCRYPTION_KEY = "test-encryption-key-32-chars!!";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("encrypt/decrypt round-trip", () => {
|
||||
it("successfully encrypts and decrypts a string", () => {
|
||||
const plaintext = "sensitive-data-to-encrypt";
|
||||
const encrypted = encrypt(plaintext);
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles long strings", () => {
|
||||
const plaintext = "a".repeat(10000);
|
||||
const encrypted = encrypt(plaintext);
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles special characters and unicode", () => {
|
||||
const plaintext = "🔐 secrets: <>&\"' 日本語 émojis";
|
||||
const encrypted = encrypt(plaintext);
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles JSON strings (for token storage)", () => {
|
||||
const tokenData = JSON.stringify({
|
||||
oauth1: "token-abc-123",
|
||||
oauth2: "token-xyz-789",
|
||||
expires_at: "2025-12-31T00:00:00Z",
|
||||
});
|
||||
const encrypted = encrypt(tokenData);
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe(tokenData);
|
||||
expect(JSON.parse(decrypted)).toEqual(JSON.parse(tokenData));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ciphertext format", () => {
|
||||
it("returns ciphertext in iv:authTag:encrypted format", () => {
|
||||
const encrypted = encrypt("test");
|
||||
const parts = encrypted.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
// IV is 16 bytes = 32 hex chars
|
||||
expect(parts[0]).toHaveLength(32);
|
||||
// Auth tag is 16 bytes = 32 hex chars
|
||||
expect(parts[1]).toHaveLength(32);
|
||||
// Encrypted data exists
|
||||
expect(parts[2].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("produces different ciphertext for same plaintext (due to random IV)", () => {
|
||||
const plaintext = "same-text";
|
||||
const encrypted1 = encrypt(plaintext);
|
||||
const encrypted2 = encrypt(plaintext);
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// But both decrypt to the same value
|
||||
expect(decrypt(encrypted1)).toBe(plaintext);
|
||||
expect(decrypt(encrypted2)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws when ENCRYPTION_KEY is not set", () => {
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
expect(() => encrypt("test")).toThrow(
|
||||
"ENCRYPTION_KEY environment variable is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when decrypting invalid ciphertext format", () => {
|
||||
expect(() => decrypt("invalid-ciphertext")).toThrow(
|
||||
"Invalid ciphertext format",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when ciphertext is missing parts", () => {
|
||||
expect(() => decrypt("abc:def")).toThrow("Invalid ciphertext format");
|
||||
});
|
||||
|
||||
it("throws when auth tag is tampered with", () => {
|
||||
const encrypted = encrypt("test");
|
||||
const parts = encrypted.split(":");
|
||||
// Tamper with auth tag (middle part)
|
||||
const tampered = `${parts[0]}:${"0".repeat(32)}:${parts[2]}`;
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when IV is tampered with", () => {
|
||||
const encrypted = encrypt("test");
|
||||
const parts = encrypted.split(":");
|
||||
// Tamper with IV (first part)
|
||||
const tampered = `${"0".repeat(32)}:${parts[1]}:${parts[2]}`;
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when encrypted data is tampered with", () => {
|
||||
const encrypted = encrypt("test");
|
||||
const parts = encrypted.split(":");
|
||||
// Tamper with encrypted data (last part)
|
||||
const tampered = `${parts[0]}:${parts[1]}:0000000000`;
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key handling", () => {
|
||||
it("pads short keys to 32 bytes", () => {
|
||||
process.env.ENCRYPTION_KEY = "short";
|
||||
const encrypted = encrypt("test");
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe("test");
|
||||
});
|
||||
|
||||
it("truncates long keys to 32 bytes", () => {
|
||||
process.env.ENCRYPTION_KEY = "a".repeat(64);
|
||||
const encrypted = encrypt("test");
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe("test");
|
||||
});
|
||||
});
|
||||
});
|
||||
194
src/lib/garmin.test.ts
Normal file
194
src/lib/garmin.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// ABOUTME: Unit tests for Garmin Connect API client utilities.
|
||||
// ABOUTME: Tests token expiry checks, days until expiry, and API calls.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GarminTokens } from "@/types";
|
||||
|
||||
import { daysUntilExpiry, fetchGarminData, isTokenExpired } from "./garmin";
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
it("returns false when token expires in the future", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: futureDate.toISOString(),
|
||||
};
|
||||
expect(isTokenExpired(tokens)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when token has expired", () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 1);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: pastDate.toISOString(),
|
||||
};
|
||||
expect(isTokenExpired(tokens)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when token expires exactly now", () => {
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: new Date().toISOString(),
|
||||
};
|
||||
expect(isTokenExpired(tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysUntilExpiry", () => {
|
||||
it("returns positive number for future expiry dates", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: futureDate.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(29);
|
||||
expect(days).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("returns negative number for past expiry dates", () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 5);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: pastDate.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeLessThanOrEqual(-4);
|
||||
expect(days).toBeGreaterThanOrEqual(-5);
|
||||
});
|
||||
|
||||
it("returns 0 for expiry date within the same day", () => {
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: new Date().toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(-1);
|
||||
expect(days).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("calculates exactly 14 days for warning threshold", () => {
|
||||
const warningDate = new Date();
|
||||
warningDate.setDate(warningDate.getDate() + 14);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: warningDate.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(13);
|
||||
expect(days).toBeLessThanOrEqual(14);
|
||||
});
|
||||
|
||||
it("calculates exactly 7 days for critical threshold", () => {
|
||||
const criticalDate = new Date();
|
||||
criticalDate.setDate(criticalDate.getDate() + 7);
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: criticalDate.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(6);
|
||||
expect(days).toBeLessThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGarminData", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("calls Garmin API with correct URL and headers", async () => {
|
||||
const mockResponse = { data: "test" };
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
await fetchGarminData("/wellness/daily/123", {
|
||||
oauth2Token: "test-token",
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connect.garmin.com/modern/proxy/wellness/daily/123",
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
NK: "NT",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed JSON response on success", async () => {
|
||||
const mockResponse = { bodyBattery: 75, hrvStatus: "Balanced" };
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
const result = await fetchGarminData("/wellness/daily/123", {
|
||||
oauth2Token: "test-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("throws error when API returns non-ok response", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
).rejects.toThrow("Garmin API error: 401");
|
||||
});
|
||||
|
||||
it("throws error when API returns 403 forbidden", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
).rejects.toThrow("Garmin API error: 403");
|
||||
});
|
||||
|
||||
it("throws error when API returns 500 server error", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
).rejects.toThrow("Garmin API error: 500");
|
||||
});
|
||||
|
||||
it("handles network errors gracefully", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
199
src/lib/ics.test.ts
Normal file
199
src/lib/ics.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// ABOUTME: Unit tests for ICS calendar feed generation.
|
||||
// ABOUTME: Tests phase events, warning events, and ICS format validity.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateIcsFeed } from "./ics";
|
||||
|
||||
describe("generateIcsFeed", () => {
|
||||
const defaultOptions = {
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 3,
|
||||
};
|
||||
|
||||
describe("basic ICS format", () => {
|
||||
it("returns valid ICS string with required headers", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("BEGIN:VCALENDAR");
|
||||
expect(ics).toContain("VERSION:2.0");
|
||||
expect(ics).toContain("END:VCALENDAR");
|
||||
});
|
||||
|
||||
it("contains VEVENT blocks for phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("BEGIN:VEVENT");
|
||||
expect(ics).toContain("END:VEVENT");
|
||||
});
|
||||
|
||||
it("contains DTSTART and DTEND for events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("DTSTART");
|
||||
expect(ics).toContain("DTEND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase events", () => {
|
||||
it("includes MENSTRUAL phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🔵 MENSTRUAL");
|
||||
});
|
||||
|
||||
it("includes FOLLICULAR phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🟢 FOLLICULAR");
|
||||
});
|
||||
|
||||
it("includes OVULATION phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🟣 OVULATION");
|
||||
});
|
||||
|
||||
it("includes EARLY_LUTEAL phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🟡 EARLY LUTEAL");
|
||||
});
|
||||
|
||||
it("includes LATE_LUTEAL phase events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🔴 LATE LUTEAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warning events", () => {
|
||||
it("includes pre-late-luteal warning on day 22", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("⚠️ Late Luteal Phase Starts in 3 Days");
|
||||
expect(ics).toContain("Begin reducing training intensity");
|
||||
});
|
||||
|
||||
it("includes critical warning on day 25", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("🔴 CRITICAL PHASE - Gentle Rebounding Only!");
|
||||
expect(ics).toContain("Late luteal phase - protect your cycle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthsAhead parameter", () => {
|
||||
it("defaults to 3 months ahead when not specified", () => {
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
});
|
||||
// Should contain multiple cycles worth of events
|
||||
const menstrualCount = (ics.match(/🔵 MENSTRUAL/g) || []).length;
|
||||
expect(menstrualCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("generates events for specified months", () => {
|
||||
const ics1Month = generateIcsFeed({
|
||||
...defaultOptions,
|
||||
monthsAhead: 1,
|
||||
});
|
||||
const ics3Month = generateIcsFeed({
|
||||
...defaultOptions,
|
||||
monthsAhead: 3,
|
||||
});
|
||||
|
||||
// 3 months should have more events than 1 month
|
||||
expect(ics3Month.length).toBeGreaterThan(ics1Month.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle length handling", () => {
|
||||
it("handles standard 31-day cycle", () => {
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 2,
|
||||
});
|
||||
// Should complete at least one full cycle
|
||||
expect(ics).toContain("🔵 MENSTRUAL");
|
||||
expect(ics).toContain("🔴 LATE LUTEAL");
|
||||
});
|
||||
|
||||
it("handles shorter 28-day cycle", () => {
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
monthsAhead: 2,
|
||||
});
|
||||
// Should still contain all phases
|
||||
expect(ics).toContain("🔵 MENSTRUAL");
|
||||
expect(ics).toContain("🟢 FOLLICULAR");
|
||||
});
|
||||
|
||||
it("handles longer 35-day cycle", () => {
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 35,
|
||||
monthsAhead: 2,
|
||||
});
|
||||
// Should still contain all phases
|
||||
expect(ics).toContain("🔵 MENSTRUAL");
|
||||
expect(ics).toContain("🟢 FOLLICULAR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase descriptions", () => {
|
||||
it("includes training type in MENSTRUAL description", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
// MENSTRUAL phase has "gentle rebounding" training type
|
||||
expect(ics).toMatch(/MENSTRUAL[\s\S]*?gentle rebounding/i);
|
||||
});
|
||||
|
||||
it("includes training type in FOLLICULAR description", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
// FOLLICULAR phase has strength training type
|
||||
expect(ics).toMatch(/FOLLICULAR[\s\S]*?strength/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles lastPeriodDate in the future", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10);
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: futureDate,
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
});
|
||||
expect(ics).toContain("BEGIN:VCALENDAR");
|
||||
});
|
||||
|
||||
it("handles lastPeriodDate in the past", () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setMonth(pastDate.getMonth() - 3);
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: pastDate,
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
});
|
||||
expect(ics).toContain("BEGIN:VCALENDAR");
|
||||
});
|
||||
|
||||
it("returns non-empty string", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ICS specification compliance", () => {
|
||||
it("uses PRODID header", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("PRODID:");
|
||||
});
|
||||
|
||||
it("uses unique UIDs for events", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
const uidMatches = ics.match(/UID:[^\r\n]+/g) || [];
|
||||
const uniqueUids = new Set(uidMatches);
|
||||
// All UIDs should be unique
|
||||
expect(uniqueUids.size).toBe(uidMatches.length);
|
||||
});
|
||||
|
||||
it("includes SUMMARY for event titles", () => {
|
||||
const ics = generateIcsFeed(defaultOptions);
|
||||
expect(ics).toContain("SUMMARY:");
|
||||
});
|
||||
});
|
||||
});
|
||||
140
src/lib/nutrition.test.ts
Normal file
140
src/lib/nutrition.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// ABOUTME: Unit tests for nutrition guidance utilities.
|
||||
// ABOUTME: Tests seed cycling, carb ranges, and keto guidance by cycle day.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getNutritionGuidance, getSeedSwitchAlert } from "./nutrition";
|
||||
|
||||
describe("getNutritionGuidance", () => {
|
||||
describe("seed cycling", () => {
|
||||
it("returns flax + pumpkin seeds for days 1-14", () => {
|
||||
for (const day of [1, 7, 14]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.seeds).toBe("Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns sesame + sunflower seeds for days 15-31", () => {
|
||||
for (const day of [15, 20, 31]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.seeds).toBe("Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("carb ranges by cycle phase", () => {
|
||||
it("returns 100-150g carbs during menstrual phase (days 1-3)", () => {
|
||||
for (const day of [1, 2, 3]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("100-150g");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 75-100g carbs during early follicular (days 4-6)", () => {
|
||||
for (const day of [4, 5, 6]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("75-100g");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 20-100g carbs during keto window (days 7-14)", () => {
|
||||
for (const day of [7, 10, 14]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("20-100g");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 100-150g carbs during ovulation (days 15-16)", () => {
|
||||
for (const day of [15, 16]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("100-150g");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 75-125g carbs during early luteal (days 17-24)", () => {
|
||||
for (const day of [17, 20, 24]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("75-125g");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 100-150g+ carbs during late luteal (days 25-31)", () => {
|
||||
for (const day of [25, 28, 31]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.carbRange).toBe("100-150g+");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("keto guidance by cycle phase", () => {
|
||||
it("says no keto during menstrual (days 1-3)", () => {
|
||||
for (const day of [1, 2, 3]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe(
|
||||
"No - body needs carbs during menstruation",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("says no keto during transition (days 4-6)", () => {
|
||||
for (const day of [4, 5, 6]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe("No - transition phase");
|
||||
}
|
||||
});
|
||||
|
||||
it("says optional keto during optimal window (days 7-14)", () => {
|
||||
for (const day of [7, 10, 14]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe("OPTIONAL - optimal keto window");
|
||||
}
|
||||
});
|
||||
|
||||
it("says no keto during ovulation (days 15-16)", () => {
|
||||
for (const day of [15, 16]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe(
|
||||
"No - exit keto, need carbs for ovulation",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("says no keto during early luteal (days 17-24)", () => {
|
||||
for (const day of [17, 20, 24]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe("No - progesterone needs carbs");
|
||||
}
|
||||
});
|
||||
|
||||
it("says never keto during late luteal (days 25-31)", () => {
|
||||
for (const day of [25, 28, 31]) {
|
||||
const guidance = getNutritionGuidance(day);
|
||||
expect(guidance.ketoGuidance).toBe(
|
||||
"NEVER - mood/hormones need carbs for PMS",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("return value structure", () => {
|
||||
it("returns NutritionGuidance object with all fields", () => {
|
||||
const guidance = getNutritionGuidance(1);
|
||||
expect(guidance).toHaveProperty("seeds");
|
||||
expect(guidance).toHaveProperty("carbRange");
|
||||
expect(guidance).toHaveProperty("ketoGuidance");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSeedSwitchAlert", () => {
|
||||
it("returns switch alert on day 15", () => {
|
||||
const alert = getSeedSwitchAlert(15);
|
||||
expect(alert).toBe("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
||||
});
|
||||
|
||||
it("returns null on days other than 15", () => {
|
||||
for (const day of [1, 14, 16, 31]) {
|
||||
const alert = getSeedSwitchAlert(day);
|
||||
expect(alert).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user