From e12b4b62d3aa652113cdf3d23bc7db4f2abba80c Mon Sep 17 00:00:00 2001 From: adajala Date: Fri, 26 Jun 2026 11:33:30 +0100 Subject: [PATCH] feat: extended learner onboarding profile endpoint - Added SQL migration to expand learner_profiles table - Refactored DTOs with strict class-validator bounds for rich profiles - Implemented PATCH /api/v1/learners/profile for partial onboarding updates - Added GET /api/v1/learners/profile/completion for mobile progress tracking - Added unit tests for service, controller, and DTO validation --- context/progress-tracker.md | 2 + .../learners/dto/learner-profile.dto.ts | 146 ++++++++++++++---- .../learners/dto/learner-response.dto.ts | 21 ++- src/modules/learners/learners.controller.ts | 12 +- src/modules/learners/learners.service.ts | 97 +++++++++--- .../20260626111334_extend_learner_profile.sql | 15 ++ .../learners/learner-profile.dto.spec.ts | 39 +++++ .../learners/learners.controller.spec.ts | 65 ++++++++ .../modules/learners/learners.service.spec.ts | 100 ++++++++++++ 9 files changed, 435 insertions(+), 62 deletions(-) create mode 100644 supabase/migrations/20260626111334_extend_learner_profile.sql create mode 100644 test/unit/modules/learners/learner-profile.dto.spec.ts create mode 100644 test/unit/modules/learners/learners.controller.spec.ts create mode 100644 test/unit/modules/learners/learners.service.spec.ts diff --git a/context/progress-tracker.md b/context/progress-tracker.md index c5fd01f..d348c87 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -62,6 +62,8 @@ EAS build for Expo preview → then landing page → then GitHub issues → then ## In Progress +- **Learner Profile Onboarding** — Extended profile table, added PATCH /learners/profile and GET /learners/profile/completion endpoints for rich onboarding. + - **Stellar.toml endpoint** — Added `GET /.well-known/stellar.toml` via `StellarTomlController` for Stellar ecosystem discoverability (Lobstr, StellarExpert). Returns TOML metadata with org info and 4 contract IDs. Cached in-memory with 1-hour TTL. (`stellar-toml.controller.ts`, `health.module.ts`, `stellar-toml.e2e-spec.ts`) --- diff --git a/src/modules/learners/dto/learner-profile.dto.ts b/src/modules/learners/dto/learner-profile.dto.ts index 010f865..d879c52 100644 --- a/src/modules/learners/dto/learner-profile.dto.ts +++ b/src/modules/learners/dto/learner-profile.dto.ts @@ -1,56 +1,136 @@ -import { IsString, IsOptional, IsEnum, IsNumber, Min, Max } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export enum IncomeType { - EMPLOYED = 'employed', - INTERN = 'intern', - FREELANCE = 'freelance', - STUDENT = 'student', - UNEMPLOYED = 'unemployed', +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + Min, + Max, + MinLength, + MaxLength, + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsUrl, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; + +export enum CurrentRole { + STUDENT = 'Student', + INTERN = 'Intern', + EARLY_CAREER_DEV = 'EarlyCareerDev', + FREELANCER = 'Freelancer', + EMPLOYED = 'Employed', +} + +export enum Skill { + JAVASCRIPT = 'JavaScript', + TYPESCRIPT = 'TypeScript', + RUST = 'Rust', + PYTHON = 'Python', + GO = 'Go', + REACT = 'React', + REACT_NATIVE = 'React Native', + NESTJS = 'NestJS', + SOLIDITY = 'Solidity', + SOROBAN = 'Soroban', + DESIGN = 'Design', + DEVOPS = 'DevOps', + TESTING = 'Testing', + TECHNICAL_WRITING = 'Technical Writing', + OTHER = 'Other', } -export enum ProgramType { - BOOTCAMP = 'bootcamp', - UNIVERSITY = 'university', - SELF_TAUGHT = 'self_taught', - ONLINE_COURSE = 'online_course', - APPRENTICESHIP = 'apprenticeship', +export enum FinanceGoal { + LAPTOP = 'Laptop', + COURSE = 'Course', + BOOTCAMP = 'Bootcamp', + DEV_TOOLS = 'Dev Tools', + SUBSCRIPTIONS = 'Subscriptions', + BOOKS = 'Books', + OTHER = 'Other', } -export class UpdateLearnerProfileDto { +export enum MonthlyIncomeRange { + NO_INCOME = 'No Income', + UNDER_500 = 'Under $500', + RANGE_500_1000 = '$500-$1000', + RANGE_1000_5000 = '$1000-$5000', + ABOVE_5000 = 'Above $5000', +} + +export class CreateLearnerProfileDto { + @ApiProperty({ example: 'John Doe' }) + @IsString() + @MinLength(2) + @MaxLength(100) + full_name: string; + + @ApiPropertyOptional({ example: 'I am a passionate learner.' }) + @IsOptional() + @IsString() + @MaxLength(500) + bio?: string; + + @ApiProperty({ example: 'Nigeria' }) + @IsString() + country: string; + + @ApiPropertyOptional({ example: 'Lagos' }) + @IsOptional() + @IsString() + city?: string; + + @ApiPropertyOptional({ enum: CurrentRole }) + @IsOptional() + @IsEnum(CurrentRole) + current_role?: CurrentRole; + @ApiPropertyOptional({ example: 'University of Lagos' }) @IsOptional() @IsString() - school?: string; + @MaxLength(100) + institution?: string; @ApiPropertyOptional({ example: 'Computer Science' }) @IsOptional() @IsString() + @MaxLength(100) program?: string; - @ApiPropertyOptional({ enum: ProgramType }) + @ApiPropertyOptional({ example: 2024 }) @IsOptional() - @IsEnum(ProgramType) - programType?: ProgramType; + @IsNumber() + @Min(2020) + @Max(2035) + graduation_year?: number; - @ApiPropertyOptional({ enum: IncomeType }) + @ApiPropertyOptional({ enum: Skill, isArray: true, maxItems: 15 }) @IsOptional() - @IsEnum(IncomeType) - incomeType?: IncomeType; + @IsArray() + @IsEnum(Skill, { each: true }) + @ArrayMaxSize(15) + skills?: Skill[]; + + @ApiProperty({ enum: FinanceGoal, isArray: true, minItems: 1 }) + @IsArray() + @IsEnum(FinanceGoal, { each: true }) + @ArrayMinSize(1) + finance_goals: FinanceGoal[]; - @ApiPropertyOptional({ example: 500 }) + @ApiPropertyOptional({ enum: MonthlyIncomeRange }) @IsOptional() - @IsNumber() - @Min(0) - monthlyIncome?: number; + @IsEnum(MonthlyIncomeRange) + monthly_income_range?: MonthlyIncomeRange; - @ApiPropertyOptional({ example: 'Nigeria' }) + @ApiPropertyOptional({ example: 'https://github.com/johndoe' }) @IsOptional() - @IsString() - country?: string; + @IsUrl() + github_url?: string; - @ApiPropertyOptional({ example: 'Lagos' }) + @ApiPropertyOptional({ example: 'https://linkedin.com/in/johndoe' }) @IsOptional() - @IsString() - city?: string; + @IsUrl() + linkedin_url?: string; } + +export class UpdateLearnerProfileDto extends PartialType(CreateLearnerProfileDto) {} diff --git a/src/modules/learners/dto/learner-response.dto.ts b/src/modules/learners/dto/learner-response.dto.ts index 624c27e..2dc9145 100644 --- a/src/modules/learners/dto/learner-response.dto.ts +++ b/src/modules/learners/dto/learner-response.dto.ts @@ -1,17 +1,24 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IncomeType, ProgramType } from './learner-profile.dto'; +import { CurrentRole, Skill, FinanceGoal, MonthlyIncomeRange } from './learner-profile.dto'; export class LearnerResponseDto { @ApiProperty() id: string; @ApiProperty() walletAddress: string; - @ApiPropertyOptional() school?: string; - @ApiPropertyOptional() program?: string; - @ApiPropertyOptional({ enum: ProgramType }) programType?: ProgramType; - @ApiPropertyOptional({ enum: IncomeType }) incomeType?: IncomeType; - @ApiPropertyOptional() monthlyIncome?: number; + @ApiPropertyOptional() fullName?: string; + @ApiPropertyOptional() bio?: string; @ApiPropertyOptional() country?: string; @ApiPropertyOptional() city?: string; - @ApiPropertyOptional() deviceOwned?: boolean; + @ApiPropertyOptional({ enum: CurrentRole }) currentRole?: CurrentRole; + @ApiPropertyOptional() institution?: string; + @ApiPropertyOptional() program?: string; + @ApiPropertyOptional() graduationYear?: number; + @ApiPropertyOptional({ enum: Skill, isArray: true }) skills?: Skill[]; + @ApiPropertyOptional({ enum: FinanceGoal, isArray: true }) financeGoals?: FinanceGoal[]; + @ApiPropertyOptional({ enum: MonthlyIncomeRange }) monthlyIncomeRange?: MonthlyIncomeRange; + @ApiPropertyOptional() githubUrl?: string; + @ApiPropertyOptional() linkedinUrl?: string; + @ApiPropertyOptional() profileComplete?: boolean; + @ApiPropertyOptional() onboardingCompletedAt?: string; @ApiProperty() createdAt: string; @ApiProperty() updatedAt: string; } diff --git a/src/modules/learners/learners.controller.ts b/src/modules/learners/learners.controller.ts index 6b470bc..5eaa7ee 100644 --- a/src/modules/learners/learners.controller.ts +++ b/src/modules/learners/learners.controller.ts @@ -13,7 +13,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; export class LearnersController { constructor(private readonly learnersService: LearnersService) {} - @Get('me') + @Get('profile') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get learner profile' }) @ApiResponse({ status: 200, description: 'Learner profile', type: LearnerResponseDto }) @@ -22,7 +22,7 @@ export class LearnersController { return this.learnersService.getProfile(user.wallet); } - @Patch('me') + @Patch('profile') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Update learner profile' }) @ApiBody({ type: UpdateLearnerProfileDto }) @@ -33,4 +33,12 @@ export class LearnersController { ): Promise { return this.learnersService.upsertProfile(user.wallet, dto); } + + @Get('profile/completion') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get learner profile completion status' }) + @ApiResponse({ status: 200, description: 'Completion status and missing fields' }) + async getCompletionStatus(@CurrentUser() user: { wallet: string }): Promise<{ complete: boolean; missingFields: string[] }> { + return this.learnersService.getCompletionStatus(user.wallet); + } } diff --git a/src/modules/learners/learners.service.ts b/src/modules/learners/learners.service.ts index 5de92e4..fd9e943 100644 --- a/src/modules/learners/learners.service.ts +++ b/src/modules/learners/learners.service.ts @@ -28,28 +28,78 @@ export class LearnersService { return this.mapToDto(data); } + async getCompletionStatus(wallet: string): Promise<{ complete: boolean; missingFields: string[] }> { + let profile; + try { + profile = await this.getProfile(wallet); + } catch (err) { + return { complete: false, missingFields: ['fullName', 'country', 'financeGoals'] }; + } + + const requiredFields = ['fullName', 'country', 'financeGoals']; + const missingFields = requiredFields.filter(field => { + const val = profile[field as keyof LearnerResponseDto]; + if (Array.isArray(val)) return val.length === 0; + return !val; + }); + + return { + complete: missingFields.length === 0, + missingFields, + }; + } + async upsertProfile( wallet: string, dto: UpdateLearnerProfileDto, ): Promise { const client = this.supabaseService.getServiceRoleClient(); + const { data: existing } = await client + .from('learner_profiles') + .select('*') + .eq('wallet_address', wallet) + .single(); + + const fullName = dto.full_name !== undefined ? dto.full_name : existing?.full_name; + const country = dto.country !== undefined ? dto.country : existing?.country; + const financeGoals = dto.finance_goals !== undefined ? dto.finance_goals : existing?.finance_goals; + + const isComplete = !!( + fullName && + country && + Array.isArray(financeGoals) && financeGoals.length > 0 + ); + + const wasComplete = existing?.profile_complete === true; + const isNewlyComplete = isComplete && !wasComplete; + + const onboardingCompletedAt = isNewlyComplete ? new Date().toISOString() : existing?.onboarding_completed_at; + + const updatePayload: any = { + wallet_address: wallet, + profile_complete: isComplete, + onboarding_completed_at: onboardingCompletedAt, + updated_at: new Date().toISOString(), + }; + + if (dto.full_name !== undefined) updatePayload.full_name = dto.full_name; + if (dto.bio !== undefined) updatePayload.bio = dto.bio; + if (dto.country !== undefined) updatePayload.country = dto.country; + if (dto.city !== undefined) updatePayload.city = dto.city; + if (dto.current_role !== undefined) updatePayload.current_role = dto.current_role; + if (dto.institution !== undefined) updatePayload.institution = dto.institution; + if (dto.program !== undefined) updatePayload.program = dto.program; + if (dto.graduation_year !== undefined) updatePayload.graduation_year = dto.graduation_year; + if (dto.skills !== undefined) updatePayload.skills = dto.skills; + if (dto.finance_goals !== undefined) updatePayload.finance_goals = dto.finance_goals; + if (dto.monthly_income_range !== undefined) updatePayload.monthly_income_range = dto.monthly_income_range; + if (dto.github_url !== undefined) updatePayload.github_url = dto.github_url; + if (dto.linkedin_url !== undefined) updatePayload.linkedin_url = dto.linkedin_url; + const { data, error } = await client .from('learner_profiles') - .upsert( - { - wallet_address: wallet, - school: dto.school, - program: dto.program, - program_type: dto.programType, - income_type: dto.incomeType, - monthly_income: dto.monthlyIncome, - country: dto.country, - city: dto.city, - updated_at: new Date().toISOString(), - }, - { onConflict: 'wallet_address' }, - ) + .upsert(updatePayload, { onConflict: 'wallet_address' }) .select() .single(); @@ -66,14 +116,21 @@ export class LearnersService { return { id: data.id, walletAddress: data.wallet_address, - school: data.school, - program: data.program, - programType: data.program_type, - incomeType: data.income_type, - monthlyIncome: data.monthly_income, + fullName: data.full_name, + bio: data.bio, country: data.country, city: data.city, - deviceOwned: data.device_owned, + currentRole: data.current_role, + institution: data.institution, + program: data.program, + graduationYear: data.graduation_year, + skills: data.skills, + financeGoals: data.finance_goals, + monthlyIncomeRange: data.monthly_income_range, + githubUrl: data.github_url, + linkedinUrl: data.linkedin_url, + profileComplete: data.profile_complete, + onboardingCompletedAt: data.onboarding_completed_at, createdAt: data.created_at, updatedAt: data.updated_at, }; diff --git a/supabase/migrations/20260626111334_extend_learner_profile.sql b/supabase/migrations/20260626111334_extend_learner_profile.sql new file mode 100644 index 0000000..6ac7a28 --- /dev/null +++ b/supabase/migrations/20260626111334_extend_learner_profile.sql @@ -0,0 +1,15 @@ +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS full_name VARCHAR(100); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS bio TEXT; +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS country VARCHAR(60); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS city VARCHAR(60); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS current_role VARCHAR(50); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS institution VARCHAR(100); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS program VARCHAR(100); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS graduation_year INTEGER; +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS skills TEXT[]; +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS finance_goals TEXT[]; +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS monthly_income_range VARCHAR(30); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS github_url VARCHAR(200); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS linkedin_url VARCHAR(200); +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS profile_complete BOOLEAN DEFAULT FALSE; +ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ; diff --git a/test/unit/modules/learners/learner-profile.dto.spec.ts b/test/unit/modules/learners/learner-profile.dto.spec.ts new file mode 100644 index 0000000..fd04777 --- /dev/null +++ b/test/unit/modules/learners/learner-profile.dto.spec.ts @@ -0,0 +1,39 @@ +import { validate } from 'class-validator'; +import { CreateLearnerProfileDto, CurrentRole, FinanceGoal, Skill } from '../../../../src/modules/learners/dto/learner-profile.dto'; + +describe('CreateLearnerProfileDto Validation', () => { + let dto: CreateLearnerProfileDto; + + beforeEach(() => { + dto = new CreateLearnerProfileDto(); + dto.full_name = 'Test User'; + dto.country = 'Test Country'; + dto.finance_goals = [FinanceGoal.LAPTOP]; + }); + + it('should validate a valid dto', async () => { + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail if full_name is missing', async () => { + delete (dto as any).full_name; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.property === 'full_name')).toBe(true); + }); + + it('should fail if finance_goals has 0 items', async () => { + dto.finance_goals = []; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.property === 'finance_goals')).toBe(true); + }); + + it('should fail if skills has more than 15 items', async () => { + dto.skills = Array(16).fill(Skill.JAVASCRIPT); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.property === 'skills')).toBe(true); + }); +}); diff --git a/test/unit/modules/learners/learners.controller.spec.ts b/test/unit/modules/learners/learners.controller.spec.ts new file mode 100644 index 0000000..6efe7d1 --- /dev/null +++ b/test/unit/modules/learners/learners.controller.spec.ts @@ -0,0 +1,65 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LearnersController } from '../../../../src/modules/learners/learners.controller'; +import { LearnersService } from '../../../../src/modules/learners/learners.service'; +import { UpdateLearnerProfileDto } from '../../../../src/modules/learners/dto/learner-profile.dto'; + +describe('LearnersController', () => { + let controller: LearnersController; + let service: LearnersService; + + const mockService = { + getProfile: jest.fn(), + upsertProfile: jest.fn(), + getCompletionStatus: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LearnersController], + providers: [ + { + provide: LearnersService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(LearnersController); + service = module.get(LearnersService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getProfile', () => { + it('should call service getProfile', async () => { + const user = { wallet: '0x123' }; + mockService.getProfile.mockResolvedValueOnce({ walletAddress: '0x123' }); + const result = await controller.getProfile(user); + expect(service.getProfile).toHaveBeenCalledWith('0x123'); + expect(result).toEqual({ walletAddress: '0x123' }); + }); + }); + + describe('updateProfile', () => { + it('should call service upsertProfile', async () => { + const user = { wallet: '0x123' }; + const dto: UpdateLearnerProfileDto = { full_name: 'Test' }; + mockService.upsertProfile.mockResolvedValueOnce({ fullName: 'Test' }); + const result = await controller.updateProfile(user, dto); + expect(service.upsertProfile).toHaveBeenCalledWith('0x123', dto); + expect(result).toEqual({ fullName: 'Test' }); + }); + }); + + describe('getCompletionStatus', () => { + it('should call service getCompletionStatus', async () => { + const user = { wallet: '0x123' }; + mockService.getCompletionStatus.mockResolvedValueOnce({ complete: true, missingFields: [] }); + const result = await controller.getCompletionStatus(user); + expect(service.getCompletionStatus).toHaveBeenCalledWith('0x123'); + expect(result).toEqual({ complete: true, missingFields: [] }); + }); + }); +}); diff --git a/test/unit/modules/learners/learners.service.spec.ts b/test/unit/modules/learners/learners.service.spec.ts new file mode 100644 index 0000000..a1b7f07 --- /dev/null +++ b/test/unit/modules/learners/learners.service.spec.ts @@ -0,0 +1,100 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LearnersService } from '../../../../src/modules/learners/learners.service'; +import { SupabaseService } from '../../../../src/database/supabase.client'; +import { NotFoundException } from '@nestjs/common'; +import { UpdateLearnerProfileDto, FinanceGoal } from '../../../../src/modules/learners/dto/learner-profile.dto'; + +describe('LearnersService', () => { + let service: LearnersService; + let supabaseService: SupabaseService; + + const mockWallet = '0x123'; + const mockExistingProfile = { + wallet_address: mockWallet, + full_name: 'Test', + country: 'US', + finance_goals: [], + profile_complete: false, + onboarding_completed_at: null, + }; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn(), + upsert: jest.fn().mockReturnThis(), + }; + + const mockSupabaseClient = { + from: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LearnersService, + { + provide: SupabaseService, + useValue: { + getClient: jest.fn().mockReturnValue(mockSupabaseClient), + getServiceRoleClient: jest.fn().mockReturnValue(mockSupabaseClient), + }, + }, + ], + }).compile(); + + service = module.get(LearnersService); + supabaseService = module.get(SupabaseService); + + jest.clearAllMocks(); + }); + + describe('getProfile', () => { + it('should return profile if found', async () => { + mockQueryBuilder.single.mockResolvedValueOnce({ data: mockExistingProfile, error: null }); + const result = await service.getProfile(mockWallet); + expect(result.walletAddress).toBe(mockWallet); + expect(result.fullName).toBe('Test'); + }); + + it('should throw NotFoundException if profile not found', async () => { + mockQueryBuilder.single.mockResolvedValueOnce({ data: null, error: { message: 'Not found' } }); + await expect(service.getProfile(mockWallet)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getCompletionStatus', () => { + it('should return missing fields if incomplete', async () => { + mockQueryBuilder.single.mockResolvedValueOnce({ data: mockExistingProfile, error: null }); + const result = await service.getCompletionStatus(mockWallet); + expect(result.complete).toBe(false); + expect(result.missingFields).toContain('financeGoals'); + }); + + it('should handle missing profile by returning all required fields missing', async () => { + mockQueryBuilder.single.mockResolvedValueOnce({ data: null, error: { message: 'Not found' } }); + const result = await service.getCompletionStatus(mockWallet); + expect(result.complete).toBe(false); + expect(result.missingFields).toEqual(['fullName', 'country', 'financeGoals']); + }); + }); + + describe('upsertProfile', () => { + it('should set profile_complete to true when all required fields are provided', async () => { + mockQueryBuilder.single.mockResolvedValueOnce({ data: mockExistingProfile, error: null }); + mockQueryBuilder.single.mockResolvedValueOnce({ + data: { ...mockExistingProfile, profile_complete: true, onboarding_completed_at: new Date().toISOString() }, + error: null, + }); + + const dto: UpdateLearnerProfileDto = { finance_goals: [FinanceGoal.LAPTOP] }; + const result = await service.upsertProfile(mockWallet, dto); + + expect(mockQueryBuilder.upsert).toHaveBeenCalledWith( + expect.objectContaining({ profile_complete: true, onboarding_completed_at: expect.any(String) }), + expect.any(Object) + ); + expect(result.profileComplete).toBe(true); + }); + }); +});