From 939a5175499dfd63d53c7882c7713fd5e033e9d0 Mon Sep 17 00:00:00 2001 From: Ezeh Date: Thu, 30 Jul 2026 00:47:39 +0100 Subject: [PATCH] feat(profile): add learner public/private profile schema --- .../0002-learner-profile-visibility.md | 49 +++++ .../migration.sql | 34 ++++ prisma/schema.prisma | 27 +++ src/controllers/profile.controller.ts | 156 +++++++++++++++ src/routes/v1/users.routes.ts | 10 +- src/services/profile-serializer.ts | 83 ++++++++ src/services/profile.service.ts | 66 +++++++ src/types/profile.types.ts | 84 +++++++++ tests/profile-serializer.test.ts | 153 +++++++++++++++ tests/profile.controller.test.ts | 177 ++++++++++++++++++ tests/profile.service.test.ts | 144 ++++++++++++++ 11 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0002-learner-profile-visibility.md create mode 100644 prisma/migrations/20260730120000_add_learner_profiles/migration.sql create mode 100644 src/controllers/profile.controller.ts create mode 100644 src/services/profile-serializer.ts create mode 100644 src/services/profile.service.ts create mode 100644 src/types/profile.types.ts create mode 100644 tests/profile-serializer.test.ts create mode 100644 tests/profile.controller.test.ts create mode 100644 tests/profile.service.test.ts diff --git a/docs/decisions/0002-learner-profile-visibility.md b/docs/decisions/0002-learner-profile-visibility.md new file mode 100644 index 0000000..823b312 --- /dev/null +++ b/docs/decisions/0002-learner-profile-visibility.md @@ -0,0 +1,49 @@ +# 0002 — Learner Public and Private Profile Schema + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Roadmap item:** Phase 1 / "Add Learner Public and Private Profile Schema" (issue #83) + +## Context + +Learner-facing account data was split across `User` (identity, credentials, account status/verification) and `LearnerPreference` (locale, accessibility, a coarse `profileVisibility` flag used only for search/preference purposes). There was no structured, consent-controlled profile — no bio, avatar, country, languages, interests, or goals — and no defined boundary for what an employer or the public may see versus what only the account owner may see. + +## Decision + +Add a `LearnerProfile` model (`prisma/schema.prisma`), one-to-one with `User`, holding only consent-controlled profile data: + +- `displayName`, `bio`, `avatarUrl`, `country`, `timezone` — scalar fields. +- `languages`, `interests`, `goals` — `String[]` arrays, modeled consistently with the existing `LearnerPreference.preferredCategories` convention. +- `level` — bounded string (`beginner | intermediate | advanced | expert`). +- `visibility` — bounded string (`private | employer | public`), the single disclosure control for the whole profile. `country`, `level`, and `visibility` are indexed as the consented, searchable fields. + +Account status/verification fields (`status`, `isVerified`, `phoneVerifiedAt`) already live on `User` from prior work and are deliberately **not** duplicated onto `LearnerProfile`. This decision's job is to guarantee they never leak through a profile response, not to re-model them. + +### Visibility model + +`visibility` is ranked `private (0) < employer (1) < public (2)` (`VISIBILITY_RANK` in `src/types/profile.types.ts`). A viewer at a given tier sees the full field set only if the profile's visibility rank is at or above that tier; otherwise they get a redacted stub (`{ id, visible: false }`). This keeps the rule in one place instead of scattered `if` checks in controllers. + +### Serializers + +Four pure functions in `src/services/profile-serializer.ts`, each taking the raw DB record and returning a fixed shape: + +- `toOwnerProfile` — every profile field, always, plus a computed `completion` summary. Ignores `visibility` because the rule doesn't apply to the owner's own view. +- `toEmployerProfile` — full field set if `visibility >= employer`, else redacted stub. +- `toPublicProfile` — a narrower public-safe subset (excludes `timezone`, `languages`, `goals`) if `visibility === public`, else redacted stub. +- `toPrivateProfile` — internal/admin use only; joins the profile with `AccountPrivateFields` (`status`, `isVerified`, `phoneVerifiedAt`) from `User`. Never wired to a public or employer-facing route. + +Being pure functions with no I/O, leakage is testable directly: `tests/profile-serializer.test.ts` asserts the employer and public views never carry `status`/`isVerified`/`phoneVerifiedAt`, and that redaction kicks in below each tier's visibility threshold. + +### Completion + +`computeProfileCompletion` (same file) is a pure function over eight tracked fields (`PROFILE_COMPLETION_FIELDS`) — every profile field except `level`, which always has a default and can never read as "empty". Completion is computed on read rather than stored, so it can never drift from the underlying data; the same input always produces the same percentage. + +### Routes + +- `GET/PATCH /users/me/profile` — owner view and partial update, mirroring the existing `LearnerPreference` endpoints' shape and auth. +- `GET /users/:id/profile` — optionally authenticated; returns the owner view if the caller is viewing themselves, the employer view if `req.user.role === 'employer'`, otherwise the public view. + +## Out of scope + +- Wiring `LearnerProfile` into the existing mock-backed `UserController` / `employer.controller.ts` candidate search — tracked separately by "Replace Mock User Helpers with Profile API". +- Change auditing on profile edits — tracked separately by "Add Auditable Data Lifecycle and Archive Policy"; `LearnerProfile` intentionally has no audit log of its own yet, unlike `LearnerPreference`'s privacy-impacting-field audit. diff --git a/prisma/migrations/20260730120000_add_learner_profiles/migration.sql b/prisma/migrations/20260730120000_add_learner_profiles/migration.sql new file mode 100644 index 0000000..a2f59c0 --- /dev/null +++ b/prisma/migrations/20260730120000_add_learner_profiles/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "learner_profiles" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "displayName" TEXT, + "bio" TEXT, + "avatarUrl" TEXT, + "country" TEXT, + "timezone" TEXT, + "languages" TEXT[] DEFAULT ARRAY[]::TEXT[], + "level" TEXT NOT NULL DEFAULT 'beginner', + "interests" TEXT[] DEFAULT ARRAY[]::TEXT[], + "goals" TEXT[] DEFAULT ARRAY[]::TEXT[], + "visibility" TEXT NOT NULL DEFAULT 'private', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "learner_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "learner_profiles_userId_key" ON "learner_profiles"("userId"); + +-- CreateIndex +CREATE INDEX "learner_profiles_country_idx" ON "learner_profiles"("country"); + +-- CreateIndex +CREATE INDEX "learner_profiles_level_idx" ON "learner_profiles"("level"); + +-- CreateIndex +CREATE INDEX "learner_profiles_visibility_idx" ON "learner_profiles"("visibility"); + +-- AddForeignKey +ALTER TABLE "learner_profiles" ADD CONSTRAINT "learner_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e831d87..ad44262 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { emailDeliveries EmailDelivery[] learnerPreference LearnerPreference? preferenceAuditLogs PreferenceAuditLog[] + learnerProfile LearnerProfile? sessions Session[] audits AuditLog[] dataExports DataExportRequest[] @@ -77,6 +78,32 @@ model LearnerPreference { @@map("learner_preferences") } +model LearnerProfile { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + displayName String? + bio String? + avatarUrl String? + country String? + timezone String? + languages String[] @default([]) + level String @default("beginner") // beginner, intermediate, advanced, expert + interests String[] @default([]) + goals String[] @default([]) + + visibility String @default("private") // private, employer, public + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([country]) + @@index([level]) + @@index([visibility]) + @@map("learner_profiles") +} + model PreferenceAuditLog { id String @id @default(uuid()) userId String diff --git a/src/controllers/profile.controller.ts b/src/controllers/profile.controller.ts new file mode 100644 index 0000000..5a00998 --- /dev/null +++ b/src/controllers/profile.controller.ts @@ -0,0 +1,156 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { ProfileService } from '../services/profile.service' +import { LEARNER_LEVELS, PROFILE_VISIBILITIES } from '../types/profile.types' + +const profileService = new ProfileService() + +const updateProfileSchema = z + .object({ + displayName: z.string().min(1).max(80).nullable().optional(), + bio: z.string().max(1000).nullable().optional(), + avatarUrl: z.string().url().nullable().optional(), + country: z.string().min(2).max(60).nullable().optional(), + timezone: z.string().nullable().optional(), + languages: z.array(z.string().min(1)).max(20).optional(), + level: z.enum(LEARNER_LEVELS, { + errorMap: () => ({ message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}` }), + }).optional(), + interests: z.array(z.string().min(1)).max(50).optional(), + goals: z.array(z.string().min(1)).max(20).optional(), + visibility: z.enum(PROFILE_VISIBILITIES, { + errorMap: () => ({ message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), + }).optional(), + }) + .strict() + .refine(data => Object.keys(data).length > 0, { message: 'At least one profile field is required' }) + +export class ProfileController { + /** + * @openapi + * /users/me/profile: + * get: + * summary: Get the authenticated user's full learner profile + * tags: [Profiles] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile retrieved successfully + * 401: + * description: Unauthorized + */ + async getMyProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const profile = await profileService.getOwnerView(userId) + + res.status(200).json({ data: profile }) + } catch (error) { + console.error('Get profile error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/me/profile: + * patch: + * summary: Partially update the authenticated user's learner profile + * tags: [Profiles] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile updated successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async updateMyProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = updateProfileSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + await profileService.updateProfile(userId, validation.data) + const profile = await profileService.getOwnerView(userId) + + res.status(200).json({ + message: 'Profile updated successfully', + data: profile, + }) + } catch (error) { + console.error('Update profile error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/{id}/profile: + * get: + * summary: Get a learner's profile, scoped to the caller's visibility level + * tags: [Profiles] + * security: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Profile retrieved successfully, subject to the owner's visibility setting + * 404: + * description: Profile not found + */ + async getProfileById(req: Request, res: Response): Promise { + try { + const { id } = req.params + + if (req.user?.id === id) { + const profile = await profileService.getOwnerView(id) + res.status(200).json({ data: profile }) + + return + } + + const profile = req.user?.role === 'employer' + ? await profileService.getEmployerView(id) + : await profileService.getPublicView(id) + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }) + + return + } + + res.status(200).json({ data: profile }) + } catch (error) { + console.error('Get profile by id error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index 87b9633..7ddb93f 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -1,12 +1,14 @@ import express, { Router } from 'express' import { UserController } from '../../controllers/user.controller' import { PreferenceController } from '../../controllers/preference.controller' -import { authenticate } from '../../middleware/auth.middleware' +import { ProfileController } from '../../controllers/profile.controller' +import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' import { validateProfileUpdate, validatePasswordChange, validateWalletAddress } from '../../middleware/validation.middleware' const router: express.Router = Router() const userController = new UserController() const preferenceController = new PreferenceController() +const profileController = new ProfileController() router.get('/me', authenticate, userController.getCurrentUser.bind(userController)) @@ -16,6 +18,12 @@ router.get('/me/preferences', authenticate, preferenceController.getPreferences. router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController)) +router.get('/me/profile', authenticate, profileController.getMyProfile.bind(profileController)) + +router.patch('/me/profile', authenticate, profileController.updateMyProfile.bind(profileController)) + +router.get('/:id/profile', optionalAuthenticate, profileController.getProfileById.bind(profileController)) + router.get('/:id', userController.getUserById.bind(userController)) router.patch('/password', authenticate, validatePasswordChange, userController.changePassword.bind(userController)) diff --git a/src/services/profile-serializer.ts b/src/services/profile-serializer.ts new file mode 100644 index 0000000..33d7a82 --- /dev/null +++ b/src/services/profile-serializer.ts @@ -0,0 +1,83 @@ +import { + AccountPrivateFields, + EmployerProfileView, + LearnerProfileRecord, + OwnerProfileView, + PrivateProfileView, + PROFILE_COMPLETION_FIELDS, + ProfileCompletion, + PublicProfileView, + VISIBILITY_RANK, +} from '../types/profile.types' + +function isFilled(value: unknown): boolean { + if (value === null || value === undefined) { + return false + } + if (Array.isArray(value)) { + return value.length > 0 + } + if (typeof value === 'string') { + return value.trim().length > 0 + } + + return true +} + +export function computeProfileCompletion(profile: LearnerProfileRecord): ProfileCompletion { + const missingFields = PROFILE_COMPLETION_FIELDS.filter(field => !isFilled(profile[field])) + const filledCount = PROFILE_COMPLETION_FIELDS.length - missingFields.length + const percent = Math.round((filledCount / PROFILE_COMPLETION_FIELDS.length) * 100) + + return { percent, missingFields: [...missingFields] } +} + +export function toOwnerProfile(profile: LearnerProfileRecord): OwnerProfileView { + return { ...profile, completion: computeProfileCompletion(profile) } +} + +export function toEmployerProfile(profile: LearnerProfileRecord): EmployerProfileView { + if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.employer) { + return { id: profile.id, visible: false } + } + + return { + id: profile.id, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + country: profile.country, + timezone: profile.timezone, + languages: profile.languages, + level: profile.level, + interests: profile.interests, + goals: profile.goals, + visible: true, + } +} + +export function toPublicProfile(profile: LearnerProfileRecord): PublicProfileView { + if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.public) { + return { id: profile.id, visible: false } + } + + return { + id: profile.id, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + country: profile.country, + level: profile.level, + interests: profile.interests, + visible: true, + } +} + +// Internal/administrative use only (e.g. audit tooling). Never route this +// view through a public or employer-facing endpoint. +export function toPrivateProfile( + profile: LearnerProfileRecord, + account: AccountPrivateFields +): PrivateProfileView { + return { ...profile, ...account } +} diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts new file mode 100644 index 0000000..901ab2c --- /dev/null +++ b/src/services/profile.service.ts @@ -0,0 +1,66 @@ +import prisma from '../config/database' +import { LearnerProfileRecord, UpdateLearnerProfileData } from '../types/profile.types' +import { + toEmployerProfile, + toOwnerProfile, + toPrivateProfile, + toPublicProfile, +} from './profile-serializer' + +export class ProfileService { + async getOrCreateProfile(userId: string): Promise { + return prisma.learnerProfile.upsert({ + where: { userId }, + update: {}, + create: { userId }, + }) as unknown as LearnerProfileRecord + } + + async updateProfile(userId: string, data: UpdateLearnerProfileData): Promise { + return prisma.learnerProfile.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }) as unknown as LearnerProfileRecord + } + + async getOwnerView(userId: string) { + const profile = await this.getOrCreateProfile(userId) + + return toOwnerProfile(profile) + } + + async getEmployerView(userId: string) { + const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) + if (!profile) { + return null + } + + return toEmployerProfile(profile as unknown as LearnerProfileRecord) + } + + async getPublicView(userId: string) { + const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) + if (!profile) { + return null + } + + return toPublicProfile(profile as unknown as LearnerProfileRecord) + } + + async getPrivateView(userId: string) { + const [profile, user] = await Promise.all([ + this.getOrCreateProfile(userId), + prisma.user.findUnique({ + where: { id: userId }, + select: { status: true, isVerified: true, phoneVerifiedAt: true }, + }), + ]) + + if (!user) { + return null + } + + return toPrivateProfile(profile, user) + } +} diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts new file mode 100644 index 0000000..4f49c4e --- /dev/null +++ b/src/types/profile.types.ts @@ -0,0 +1,84 @@ +export const LEARNER_LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'] as const +export type LearnerLevel = typeof LEARNER_LEVELS[number] + +export const PROFILE_VISIBILITIES = ['private', 'employer', 'public'] as const +export type ProfileVisibility = typeof PROFILE_VISIBILITIES[number] + +// Ordered from most to least restrictive. A profile's `visibility` is the +// widest audience allowed to see its non-private fields; the owner can +// always see the full record regardless of this setting. +export const VISIBILITY_RANK: Record = { + private: 0, + employer: 1, + public: 2, +} + +export interface LearnerProfileRecord { + id: string + userId: string + displayName: string | null + bio: string | null + avatarUrl: string | null + country: string | null + timezone: string | null + languages: string[] + level: LearnerLevel + interests: string[] + goals: string[] + visibility: ProfileVisibility + createdAt: Date + updatedAt: Date +} + +export interface UpdateLearnerProfileData { + displayName?: string | null + bio?: string | null + avatarUrl?: string | null + country?: string | null + timezone?: string | null + languages?: string[] + level?: LearnerLevel + interests?: string[] + goals?: string[] + visibility?: ProfileVisibility +} + +// Private account fields, sourced from `User`, that must never appear in +// any profile serializer other than the internal/private one. +export interface AccountPrivateFields { + status: string + isVerified: boolean + phoneVerifiedAt: Date | null +} + +export interface ProfileCompletion { + percent: number + missingFields: string[] +} + +export interface OwnerProfileView extends LearnerProfileRecord { + completion: ProfileCompletion +} + +export type EmployerProfileView = + | (Pick & { visible: true }) + | { id: string; visible: false } + +export type PublicProfileView = + | (Pick & { visible: true }) + | { id: string; visible: false } + +export interface PrivateProfileView extends LearnerProfileRecord, AccountPrivateFields {} + +// Fields counted toward profile-completion percentage. `level` is excluded +// because it always has a default value and can never read as "empty". +export const PROFILE_COMPLETION_FIELDS = [ + 'displayName', + 'bio', + 'avatarUrl', + 'country', + 'timezone', + 'languages', + 'interests', + 'goals', +] as const diff --git a/tests/profile-serializer.test.ts b/tests/profile-serializer.test.ts new file mode 100644 index 0000000..4bb68fd --- /dev/null +++ b/tests/profile-serializer.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest' +import { + computeProfileCompletion, + toEmployerProfile, + toOwnerProfile, + toPrivateProfile, + toPublicProfile, +} from '../src/services/profile-serializer' +import { LearnerProfileRecord } from '../src/types/profile.types' + +const baseProfile: LearnerProfileRecord = { + id: 'profile1', + userId: 'user1', + displayName: 'Ada Learner', + bio: 'Building on Stellar', + avatarUrl: 'https://cdn.example.com/a.png', + country: 'NG', + timezone: 'Africa/Lagos', + languages: ['en', 'ig'], + level: 'intermediate', + interests: ['blockchain'], + goals: ['finish course'], + visibility: 'public', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), +} + +describe('computeProfileCompletion', () => { + it('is 100% when every tracked field is filled', () => { + expect(computeProfileCompletion(baseProfile)).toEqual({ percent: 100, missingFields: [] }) + }) + + it('is deterministic for the same input', () => { + const first = computeProfileCompletion(baseProfile) + const second = computeProfileCompletion(baseProfile) + + expect(first).toEqual(second) + }) + + it('lists missing fields for an empty profile', () => { + const empty: LearnerProfileRecord = { + ...baseProfile, + displayName: null, + bio: null, + avatarUrl: null, + country: null, + timezone: null, + languages: [], + interests: [], + goals: [], + } + + const result = computeProfileCompletion(empty) + + expect(result.percent).toBe(0) + expect(result.missingFields).toEqual([ + 'displayName', 'bio', 'avatarUrl', 'country', 'timezone', 'languages', 'interests', 'goals', + ]) + }) + + it('does not count level, since it always has a default', () => { + const result = computeProfileCompletion(baseProfile) + + expect(result.missingFields).not.toContain('level') + }) +}) + +describe('toOwnerProfile', () => { + it('always returns every field regardless of visibility', () => { + const privateProfile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + + const view = toOwnerProfile(privateProfile) + + expect(view.displayName).toBe('Ada Learner') + expect(view.bio).toBe('Building on Stellar') + expect(view.completion.percent).toBe(100) + }) +}) + +describe('toEmployerProfile', () => { + it('redacts fields when visibility is private', () => { + const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + + const view = toEmployerProfile(profile) + + expect(view).toEqual({ id: profile.id, visible: false }) + }) + + it('exposes fields when visibility is employer', () => { + const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + + const view = toEmployerProfile(profile) + + expect(view.visible).toBe(true) + if (view.visible) { + expect(view.displayName).toBe('Ada Learner') + } + }) + + it('exposes fields when visibility is public', () => { + const view = toEmployerProfile(baseProfile) + + expect(view.visible).toBe(true) + }) + + it('never includes account-private fields', () => { + const view = toEmployerProfile({ ...baseProfile, visibility: 'employer' }) as Record + + expect(view).not.toHaveProperty('status') + expect(view).not.toHaveProperty('isVerified') + expect(view).not.toHaveProperty('phoneVerifiedAt') + }) +}) + +describe('toPublicProfile', () => { + it('redacts fields unless visibility is public', () => { + const employerOnly: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + + expect(toPublicProfile(employerOnly)).toEqual({ id: employerOnly.id, visible: false }) + }) + + it('exposes only the public-safe subset when visibility is public', () => { + const view = toPublicProfile(baseProfile) + + expect(view.visible).toBe(true) + if (view.visible) { + expect(view).not.toHaveProperty('goals') + expect(view).not.toHaveProperty('timezone') + expect(view).not.toHaveProperty('languages') + } + }) + + it('never includes account-private fields', () => { + const view = toPublicProfile(baseProfile) as Record + + expect(view).not.toHaveProperty('status') + expect(view).not.toHaveProperty('isVerified') + expect(view).not.toHaveProperty('phoneVerifiedAt') + }) +}) + +describe('toPrivateProfile', () => { + it('is the only serializer that carries account-private fields', () => { + const view = toPrivateProfile(baseProfile, { + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + }) + + expect(view.status).toBe('ACTIVE') + expect(view.isVerified).toBe(true) + }) +}) diff --git a/tests/profile.controller.test.ts b/tests/profile.controller.test.ts new file mode 100644 index 0000000..fd6ca38 --- /dev/null +++ b/tests/profile.controller.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProfileController } from '../src/controllers/profile.controller' + +const { mockGetOwnerView, mockUpdateProfile, mockGetEmployerView, mockGetPublicView } = vi.hoisted(() => ({ + mockGetOwnerView: vi.fn(), + mockUpdateProfile: vi.fn(), + mockGetEmployerView: vi.fn(), + mockGetPublicView: vi.fn(), +})) + +vi.mock('../src/services/profile.service', () => ({ + ProfileService: class { + getOwnerView = mockGetOwnerView + updateProfile = mockUpdateProfile + getEmployerView = mockGetEmployerView + getPublicView = mockGetPublicView + }, +})) + +describe('ProfileController', () => { + let controller: ProfileController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new ProfileController() + req = { user: { id: 'user1' }, body: {}, params: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getMyProfile', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns the owner view on success', async () => { + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: { id: 'profile1', displayName: 'Ada' } }) + }) + + it('returns 500 on unexpected error', async () => { + mockGetOwnerView.mockRejectedValue(new Error('db down')) + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updateMyProfile', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { displayName: 'Ada' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 on empty body', async () => { + req.body = {} + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on unknown fields (stable, closed schema)', async () => { + req.body = { notARealField: true } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid level', async () => { + req.body = { level: 'not-a-level' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid visibility', async () => { + req.body = { visibility: 'friends' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('accepts a partial update', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfile.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.updateMyProfile(req, res) + + expect(mockUpdateProfile).toHaveBeenCalledWith('user1', { displayName: 'Ada' }) + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfile.mockRejectedValue(new Error('db down')) + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('getProfileById', () => { + it('returns the owner view when the caller requests their own profile', async () => { + req.params = { id: 'user1' } + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.getProfileById(req, res) + + expect(mockGetOwnerView).toHaveBeenCalledWith('user1') + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns the employer view for an employer caller viewing someone else', async () => { + req.user = { id: 'employer1', role: 'employer' } + req.params = { id: 'user2' } + mockGetEmployerView.mockResolvedValue({ id: 'profile2', visible: true }) + + await controller.getProfileById(req, res) + + expect(mockGetEmployerView).toHaveBeenCalledWith('user2') + expect(mockGetPublicView).not.toHaveBeenCalled() + }) + + it('returns the public view for an anonymous caller', async () => { + req.user = undefined + req.params = { id: 'user2' } + mockGetPublicView.mockResolvedValue({ id: 'profile2', visible: true }) + + await controller.getProfileById(req, res) + + expect(mockGetPublicView).toHaveBeenCalledWith('user2') + }) + + it('returns 404 when the profile is not found', async () => { + req.user = undefined + req.params = { id: 'missing' } + mockGetPublicView.mockResolvedValue(null) + + await controller.getProfileById(req, res) + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('returns 500 on unexpected error', async () => { + req.user = undefined + req.params = { id: 'user2' } + mockGetPublicView.mockRejectedValue(new Error('db down')) + + await controller.getProfileById(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +}) diff --git a/tests/profile.service.test.ts b/tests/profile.service.test.ts new file mode 100644 index 0000000..4f2bdfc --- /dev/null +++ b/tests/profile.service.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProfileService } from '../src/services/profile.service' + +const { mockUpsert, mockFindUnique, mockUserFindUnique } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockFindUnique: vi.fn(), + mockUserFindUnique: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + learnerProfile: { + upsert: mockUpsert, + findUnique: mockFindUnique, + }, + user: { + findUnique: mockUserFindUnique, + }, + }, +})) + +const baseProfile = { + id: 'profile1', + userId: 'user1', + displayName: 'Ada', + bio: null, + avatarUrl: null, + country: null, + timezone: null, + languages: [], + level: 'beginner', + interests: [], + goals: [], + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), +} + +describe('ProfileService', () => { + let service: ProfileService + + beforeEach(() => { + vi.clearAllMocks() + service = new ProfileService() + }) + + describe('getOrCreateProfile', () => { + it('upserts a default row so every learner gets a deterministic starting profile', async () => { + mockUpsert.mockResolvedValue(baseProfile) + + const result = await service.getOrCreateProfile('user1') + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: {}, + create: { userId: 'user1' }, + }) + expect(result).toEqual(baseProfile) + }) + }) + + describe('updateProfile', () => { + it('preserves omitted fields via a partial upsert', async () => { + mockUpsert.mockResolvedValue({ ...baseProfile, displayName: 'New Name' }) + + const result = await service.updateProfile('user1', { displayName: 'New Name' }) + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { displayName: 'New Name' }, + create: { userId: 'user1', displayName: 'New Name' }, + }) + expect(result.displayName).toBe('New Name') + }) + }) + + describe('getOwnerView', () => { + it('returns the full profile with a completion summary', async () => { + mockUpsert.mockResolvedValue(baseProfile) + + const result = await service.getOwnerView('user1') + + expect(result.displayName).toBe('Ada') + expect(result.completion).toBeDefined() + }) + }) + + describe('getEmployerView', () => { + it('returns null when the profile does not exist', async () => { + mockFindUnique.mockResolvedValue(null) + + const result = await service.getEmployerView('missing') + + expect(result).toBeNull() + }) + + it('redacts fields when visibility is below employer', async () => { + mockFindUnique.mockResolvedValue(baseProfile) + + const result = await service.getEmployerView('user1') + + expect(result).toEqual({ id: 'profile1', visible: false }) + }) + }) + + describe('getPublicView', () => { + it('redacts fields when visibility is below public', async () => { + mockFindUnique.mockResolvedValue(baseProfile) + + const result = await service.getPublicView('user1') + + expect(result).toEqual({ id: 'profile1', visible: false }) + }) + + it('exposes the public subset when visibility is public', async () => { + mockFindUnique.mockResolvedValue({ ...baseProfile, visibility: 'public', displayName: 'Ada' }) + + const result = await service.getPublicView('user1') + + expect(result).toMatchObject({ visible: true, displayName: 'Ada' }) + expect(result).not.toHaveProperty('goals') + }) + }) + + describe('getPrivateView', () => { + it('returns null when the account does not exist', async () => { + mockUpsert.mockResolvedValue(baseProfile) + mockUserFindUnique.mockResolvedValue(null) + + const result = await service.getPrivateView('missing') + + expect(result).toBeNull() + }) + + it('joins account-private fields onto the full profile', async () => { + mockUpsert.mockResolvedValue(baseProfile) + mockUserFindUnique.mockResolvedValue({ status: 'ACTIVE', isVerified: true, phoneVerifiedAt: null }) + + const result = await service.getPrivateView('user1') + + expect(result).toMatchObject({ displayName: 'Ada', status: 'ACTIVE', isVerified: true }) + }) + }) +})