diff --git a/__tests__/apiErrors.test.js b/__tests__/apiErrors.test.js new file mode 100644 index 000000000..205df8ea5 --- /dev/null +++ b/__tests__/apiErrors.test.js @@ -0,0 +1,131 @@ +// __tests__/apiErrors.test.js +// +// Run with: npx jest __tests__/apiErrors.test.js +// +// Tests the ApiError subclasses in src/lib/apiErrors.js. + +const { describe, expect, test } = require("@jest/globals"); +const { + ApiError, + AuthError, + RateLimitError, + ValidationError, + ConfigError, +} = require("../src/lib/apiErrors.js"); + +describe("ApiError", () => { + test("sets name, message, code and status from constructor args", () => { + const err = new ApiError("Something broke", "SOMETHING_BROKE", 503); + expect(err.name).toBe("ApiError"); + expect(err.message).toBe("Something broke"); + expect(err.code).toBe("SOMETHING_BROKE"); + expect(err.status).toBe(503); + }); + + test("uses defaults when only message is provided", () => { + const err = new ApiError("Generic error"); + expect(err.code).toBe("INTERNAL_ERROR"); + expect(err.status).toBe(500); + }); + + test("uses all defaults when no args provided", () => { + const err = new ApiError(); + expect(err.message).toBe(""); + expect(err.code).toBe("INTERNAL_ERROR"); + expect(err.status).toBe(500); + }); + + test("is an instance of Error", () => { + const err = new ApiError("test"); + expect(err instanceof Error).toBe(true); + expect(err instanceof ApiError).toBe(true); + }); +}); + +describe("AuthError", () => { + test("sets 401 status and AUTH_ERROR code", () => { + const err = new AuthError("Invalid token"); + expect(err.name).toBe("AuthError"); + expect(err.message).toBe("Invalid token"); + expect(err.code).toBe("AUTH_ERROR"); + expect(err.status).toBe(401); + }); + + test("uses default message when none provided", () => { + const err = new AuthError(); + expect(err.message).toBe("Unauthorized"); + expect(err.status).toBe(401); + }); + + test("is an instance of ApiError and Error", () => { + const err = new AuthError(); + expect(err instanceof ApiError).toBe(true); + expect(err instanceof Error).toBe(true); + }); +}); + +describe("RateLimitError", () => { + test("sets 429 status and RATE_LIMIT code", () => { + const err = new RateLimitError("Slow down"); + expect(err.name).toBe("RateLimitError"); + expect(err.message).toBe("Slow down"); + expect(err.code).toBe("RATE_LIMIT"); + expect(err.status).toBe(429); + }); + + test("uses default message when none provided", () => { + const err = new RateLimitError(); + expect(err.message).toBe("Too many requests"); + expect(err.status).toBe(429); + }); + + test("is an instance of ApiError and Error", () => { + const err = new RateLimitError(); + expect(err instanceof ApiError).toBe(true); + expect(err instanceof Error).toBe(true); + }); +}); + +describe("ValidationError", () => { + test("sets 400 status and VALIDATION_ERROR code", () => { + const err = new ValidationError("Email is required"); + expect(err.name).toBe("ValidationError"); + expect(err.message).toBe("Email is required"); + expect(err.code).toBe("VALIDATION_ERROR"); + expect(err.status).toBe(400); + }); + + test("uses default message when none provided", () => { + const err = new ValidationError(); + expect(err.message).toBe("Validation failed"); + expect(err.status).toBe(400); + }); + + test("is an instance of ApiError and Error", () => { + const err = new ValidationError(); + expect(err instanceof ApiError).toBe(true); + expect(err instanceof Error).toBe(true); + }); +}); + +describe("ConfigError", () => { + test("sets 500 status and CONFIG_ERROR code", () => { + const err = new ConfigError("Missing DB URL"); + expect(err.name).toBe("ConfigError"); + expect(err.message).toBe("Missing DB URL"); + expect(err.code).toBe("CONFIG_ERROR"); + expect(err.status).toBe(500); + }); + + test("uses default message when none provided", () => { + const err = new ConfigError(); + expect(err.message).toBe("Server configuration error"); + expect(err.status).toBe(500); + }); + + test("is an instance of ApiError and Error", () => { + const err = new ConfigError(); + expect(err instanceof ApiError).toBe(true); + expect(err instanceof Error).toBe(true); + }); +}); diff --git a/__tests__/profileUtils.test.js b/__tests__/profileUtils.test.js new file mode 100644 index 000000000..61787bb09 --- /dev/null +++ b/__tests__/profileUtils.test.js @@ -0,0 +1,357 @@ +// __tests__/profileUtils.test.js +// +// Run with: npx jest __tests__/profileUtils.test.js +// +// Tests the profile utility functions in src/lib/profileUtils.js. + +const { describe, expect, test } = require("@jest/globals"); +const { + AVATAR_BUCKET, + MAX_AVATAR_FILE_SIZE, + MAX_AVATAR_URL_LENGTH, + MAX_PROFILE_URL_LENGTH, + MAX_BIO_LENGTH, + PROFILE_URL_FIELDS, + PRESET_PROJECT_GRADIENTS, + EMPTY_PROJECT, + CODING_PLATFORMS, + ONBOARDING_OPTIONAL_FIELDS, + ONBOARDING_REQUIRED_FIELDS, + EMPTY_PROFILE_FORM, + safeAvatarUrl, + safeExternalUrl, + sanitizeProfileLinks, + buildFormDataFromUser, + normalizeProfilePayload, + validateProfileForm, + isOnboardingStep1Valid, + shouldShowProfileSetup, +} = require("../src/lib/profileUtils.js"); + +// ── Constants ──────────────────────────────────────────────────────────────── + +describe("profileUtils constants", () => { + test("AVATAR_BUCKET is 'avatars'", () => { + expect(AVATAR_BUCKET).toBe("avatars"); + }); + + test("MAX_AVATAR_FILE_SIZE is 2MB", () => { + expect(MAX_AVATAR_FILE_SIZE).toBe(2 * 1024 * 1024); + }); + + test("MAX_AVATAR_URL_LENGTH and MAX_PROFILE_URL_LENGTH are positive", () => { + expect(MAX_AVATAR_URL_LENGTH).toBeGreaterThan(0); + expect(MAX_PROFILE_URL_LENGTH).toBeGreaterThan(0); + }); + + test("MAX_BIO_LENGTH is a reasonable limit", () => { + expect(MAX_BIO_LENGTH).toBe(300); + }); + + test("PROFILE_URL_FIELDS contains expected fields", () => { + expect(PROFILE_URL_FIELDS).toContain("resume_link"); + expect(PROFILE_URL_FIELDS).toContain("github_profile"); + expect(PROFILE_URL_FIELDS).toContain("linkedin_profile"); + }); + + test("PRESET_PROJECT_GRADIENTS has at least one entry", () => { + expect(PRESET_PROJECT_GRADIENTS.length).toBeGreaterThan(0); + }); + + test("EMPTY_PROJECT has required keys", () => { + expect(EMPTY_PROJECT).toHaveProperty("title"); + expect(EMPTY_PROJECT).toHaveProperty("subtitle"); + expect(EMPTY_PROJECT).toHaveProperty("tags"); + expect(EMPTY_PROJECT).toHaveProperty("url"); + expect(EMPTY_PROJECT).toHaveProperty("preview"); + }); + + test("CODING_PLATFORMS is a non-empty array", () => { + expect(Array.isArray(CODING_PLATFORMS)).toBe(true); + expect(CODING_PLATFORMS.length).toBeGreaterThan(0); + }); + + test("ONBOARDING_REQUIRED_FIELDS includes 'name' and 'skills'", () => { + expect(ONBOARDING_REQUIRED_FIELDS).toContain("name"); + expect(ONBOARDING_REQUIRED_FIELDS).toContain("skills"); + }); + + test("EMPTY_PROFILE_FORM has all required fields", () => { + expect(EMPTY_PROFILE_FORM).toHaveProperty("name"); + expect(EMPTY_PROFILE_FORM).toHaveProperty("skills"); + expect(EMPTY_PROFILE_FORM).toHaveProperty("email_notifications"); + expect(EMPTY_PROFILE_FORM).toHaveProperty("avatar_url"); + expect(EMPTY_PROFILE_FORM).toHaveProperty("projects"); + }); +}); + +// ── safeAvatarUrl ──────────────────────────────────────────────────────────── + +describe("safeAvatarUrl", () => { + test("returns a valid URL unchanged", () => { + expect(safeAvatarUrl("https://example.com/avatar.png")).toBe("https://example.com/avatar.png"); + }); + + test("returns empty string for non-string input", () => { + expect(safeAvatarUrl(null)).toBe(""); + expect(safeAvatarUrl(undefined)).toBe(""); + expect(safeAvatarUrl(123)).toBe(""); + expect(safeAvatarUrl({})).toBe(""); + }); + + test("returns empty string for data URL", () => { + expect(safeAvatarUrl("data:image/png;base64,abc123")).toBe(""); + expect(safeAvatarUrl("data:text/html,