diff --git a/src/lib/test-utils.ts b/src/lib/test-utils.ts index 2a97ad3..a3113e0 100644 --- a/src/lib/test-utils.ts +++ b/src/lib/test-utils.ts @@ -51,6 +51,7 @@ export function mockUserWithProfile(overrides?: Record) { lastName: 'Doe', phoneNumber: '555-0100' }, + profiles: [{ id: 'profile-1', userId: 'user-1' }], ...overrides }); } @@ -76,6 +77,7 @@ export function mockUserWithProfileAndAddresses(overrides?: Record) { content: '

Test content

', solfeggioFrequencyId: 1, categoryId: 1, + groupId: 1, price: 0.99, solfeggioFrequency: { id: 1, @@ -108,9 +111,7 @@ export function mockProclamation(overrides?: Record) { }, category: { id: 1, - name: 'Healing', - parentId: null, - parent: null + name: 'Healing' }, ...overrides }; @@ -121,7 +122,16 @@ export function mockCategory(overrides?: Record) { return { id: 1, name: 'Healing', - parentId: null, + ...overrides + }; +} + +/** Minimal proclamation group. */ +export function mockGroup(overrides?: Record) { + return { + id: 1, + name: 'General', + sortOrder: 0, ...overrides }; } diff --git a/src/routes/(protected)/(user)/account/+page.server.ts b/src/routes/(protected)/(user)/account/+page.server.ts index f122bb4..e6afe7d 100644 --- a/src/routes/(protected)/(user)/account/+page.server.ts +++ b/src/routes/(protected)/(user)/account/+page.server.ts @@ -8,20 +8,18 @@ export const load: PageServerLoad = async ({ locals }) => { throw error(403, 'not authorized'); } - const user = await prisma.user.findUnique({ - where: { id: locals.user.id }, - include: { - profile: { - include: { addresses: true } - } - } - }); + const user = await prisma.user.findUnique({ where: { id: locals.user.id } }); if (!user) { throw error(403, 'not authorized'); } - return { user }; + const profile = await prisma.profile.findFirst({ + where: { userId: user.id }, + include: { addresses: true } + }); + + return { user: { ...user, profile } }; }; export const actions: Actions = { @@ -40,7 +38,7 @@ export const actions: Actions = { } try { - await prisma.profile.update({ + await prisma.profile.updateMany({ where: { userId: locals.user.id }, data: { firstName, lastName, phoneNumber } }); @@ -67,7 +65,7 @@ export const actions: Actions = { return fail(400, { error: 'All address fields are required.', action: 'addAddress' }); } - const profile = await prisma.profile.findUnique({ + const profile = await prisma.profile.findFirst({ where: { userId: locals.user.id }, select: { id: true } }); diff --git a/src/routes/(protected)/(user)/account/page.server.spec.ts b/src/routes/(protected)/(user)/account/page.server.spec.ts index 3fd4b5e..c09dc29 100644 --- a/src/routes/(protected)/(user)/account/page.server.spec.ts +++ b/src/routes/(protected)/(user)/account/page.server.spec.ts @@ -10,7 +10,7 @@ import { vi.mock('$lib/db', () => ({ prisma: { user: { findUnique: vi.fn() }, - profile: { update: vi.fn(), findUnique: vi.fn() }, + profile: { findFirst: vi.fn(), updateMany: vi.fn() }, address: { create: vi.fn() } } })); @@ -19,8 +19,8 @@ import { prisma } from '$lib/db'; import { load, actions } from './+page.server'; const userFindUnique = prisma.user.findUnique as ReturnType; -const profileUpdate = prisma.profile.update as ReturnType; -const profileFindUnique = prisma.profile.findUnique as ReturnType; +const profileFindFirst = prisma.profile.findFirst as ReturnType; +const profileUpdateMany = prisma.profile.updateMany as ReturnType; const addressCreate = prisma.address.create as ReturnType; const invokeAction = (name: string, request: Request, locals: App.Locals) => @@ -29,8 +29,8 @@ const invokeAction = (name: string, request: Request, locals: App.Locals) => describe('(protected)/account page server', () => { beforeEach(() => { userFindUnique.mockReset(); - profileUpdate.mockReset(); - profileFindUnique.mockReset(); + profileFindFirst.mockReset(); + profileUpdateMany.mockReset(); addressCreate.mockReset(); }); @@ -51,24 +51,26 @@ describe('(protected)/account page server', () => { }); it('returns user with profile and addresses', async () => { - const user = mockUserWithProfileAndAddresses(); + const { profile, ...user } = mockUserWithProfileAndAddresses() as any; userFindUnique.mockResolvedValue(user); + profileFindFirst.mockResolvedValue(profile); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; - expect(result.user).toEqual(user); + expect(result.user).toEqual({ ...user, profile }); expect(result.user.profile.addresses).toHaveLength(1); }); - it('includes addresses in the query', async () => { - userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses()); + it('loads the profile with addresses by user id', async () => { + userFindUnique.mockResolvedValue(mockUser()); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); const locals = createLocals(mockUser() as any); await load({ locals } as any); - expect(userFindUnique).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - include: { profile: { include: { addresses: true } } } + expect(profileFindFirst).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + include: { addresses: true } }); }); }); @@ -125,7 +127,7 @@ describe('(protected)/account page server', () => { }); it('updates profile with server-formatted phone number on success', async () => { - profileUpdate.mockResolvedValue({}); + profileUpdateMany.mockResolvedValue({}); const request = createFormRequest({ firstName: 'Jane', @@ -136,14 +138,14 @@ describe('(protected)/account page server', () => { const locals = createLocals(mockUser() as any); const result = await invokeAction('updateProfile', request, locals); expect(result).toEqual({ success: true, action: 'updateProfile' }); - expect(profileUpdate).toHaveBeenCalledWith({ + expect(profileUpdateMany).toHaveBeenCalledWith({ where: { userId: 'user-1' }, data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' } }); }); it('returns fail(500) when prisma update throws', async () => { - profileUpdate.mockRejectedValue(new Error('db error')); + profileUpdateMany.mockRejectedValue(new Error('db error')); const request = createFormRequest({ firstName: 'Jane', @@ -190,7 +192,7 @@ describe('(protected)/account page server', () => { }); it('throws 400 when profile is not found', async () => { - profileFindUnique.mockResolvedValue(null); + profileFindFirst.mockResolvedValue(null); const request = createFormRequest({ street: '123 Main St', @@ -208,7 +210,7 @@ describe('(protected)/account page server', () => { }); it('creates address linked to profile on success', async () => { - profileFindUnique.mockResolvedValue({ id: 'profile-1' }); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); addressCreate.mockResolvedValue({}); const request = createFormRequest({ @@ -235,7 +237,7 @@ describe('(protected)/account page server', () => { }); it('returns fail(500) when prisma create throws', async () => { - profileFindUnique.mockResolvedValue({ id: 'profile-1' }); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); addressCreate.mockRejectedValue(new Error('db error')); const request = createFormRequest({ diff --git a/src/routes/(protected)/(user)/dashboard/+page.server.ts b/src/routes/(protected)/(user)/dashboard/+page.server.ts index 83acd01..8e187e0 100644 --- a/src/routes/(protected)/(user)/dashboard/+page.server.ts +++ b/src/routes/(protected)/(user)/dashboard/+page.server.ts @@ -7,19 +7,16 @@ export const load: PageServerLoad = async ({ locals }) => { throw error(403, 'not authorized'); } - const profile = await prisma.profile.findUnique({ + const profile = await prisma.profile.findFirst({ where: { userId: locals.user.id }, select: { firstName: true } }); - const [keyStats, proclamationCount] = await Promise.all([ - prisma.licenseKey.groupBy({ - by: ['active'], - where: { userId: locals.user.id }, - _count: true - }), - prisma.proclamation.count() - ]); + const keyStats = await prisma.licenseKey.groupBy({ + by: ['active'], + where: { userId: locals.user.id }, + _count: true + }); const activeKeys = keyStats.find((s) => s.active)?._count ?? 0; const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0); @@ -27,7 +24,6 @@ export const load: PageServerLoad = async ({ locals }) => { return { firstName: profile?.firstName ?? 'User', activeKeys, - totalKeys, - proclamationCount + totalKeys }; }; diff --git a/src/routes/(protected)/(user)/dashboard/page.server.spec.ts b/src/routes/(protected)/(user)/dashboard/page.server.spec.ts index 7a9c588..fe27011 100644 --- a/src/routes/(protected)/(user)/dashboard/page.server.spec.ts +++ b/src/routes/(protected)/(user)/dashboard/page.server.spec.ts @@ -4,24 +4,21 @@ import { createLocals, mockUser } from '$lib/test-utils'; vi.mock('$lib/db', () => ({ prisma: { - profile: { findUnique: vi.fn() }, - licenseKey: { groupBy: vi.fn() }, - proclamation: { count: vi.fn() } + profile: { findFirst: vi.fn() }, + licenseKey: { groupBy: vi.fn() } } })); import { prisma } from '$lib/db'; import { load } from './+page.server'; -const profileFindUnique = prisma.profile.findUnique as ReturnType; +const profileFindFirst = prisma.profile.findFirst as ReturnType; const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType; -const proclamationCount = prisma.proclamation.count as ReturnType; describe('(protected)/dashboard page server load', () => { beforeEach(() => { - profileFindUnique.mockReset(); + profileFindFirst.mockReset(); licenseKeyGroupBy.mockReset(); - proclamationCount.mockReset(); }); it('throws 403 when locals.user is undefined', async () => { @@ -32,9 +29,8 @@ describe('(protected)/dashboard page server load', () => { }); it('returns firstName from profile', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -42,9 +38,8 @@ describe('(protected)/dashboard page server load', () => { }); it('defaults firstName to "User" when profile is null', async () => { - profileFindUnique.mockResolvedValue(null); + profileFindFirst.mockResolvedValue(null); licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -52,12 +47,11 @@ describe('(protected)/dashboard page server load', () => { }); it('computes activeKeys and totalKeys from groupBy results', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([ { active: true, _count: 3 }, { active: false, _count: 2 } ]); - proclamationCount.mockResolvedValue(10); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -66,23 +60,12 @@ describe('(protected)/dashboard page server load', () => { }); it('returns 0 for activeKeys when none are active', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; expect(result.activeKeys).toBe(0); expect(result.totalKeys).toBe(4); }); - - it('returns proclamationCount', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); - licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(42); - - const locals = createLocals(mockUser() as any); - const result = (await load({ locals } as any)) as any; - expect(result.proclamationCount).toBe(42); - }); }); diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(protected)/+layout.server.ts index 07bb9ad..8f3c813 100644 --- a/src/routes/(protected)/+layout.server.ts +++ b/src/routes/(protected)/+layout.server.ts @@ -9,12 +9,12 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { const user = await prisma.user.findUnique({ where: { id: locals.user.id }, - select: { id: true, roleId: true, profile: { select: { id: true } } } + select: { id: true, roleId: true, profiles: { select: { id: true } } } }); if (!user) { throw error(403, 'not authorized'); } - if (!user.profile && !url.pathname.startsWith('/account/setup')) { + if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) { // Skip redirect when already on /account/setup to avoid redirect loop throw redirect(303, '/account/setup'); } diff --git a/src/routes/(protected)/layout.server.spec.ts b/src/routes/(protected)/layout.server.spec.ts index 91f392b..d664dc0 100644 --- a/src/routes/(protected)/layout.server.spec.ts +++ b/src/routes/(protected)/layout.server.spec.ts @@ -40,7 +40,7 @@ describe('(protected) layout server load', () => { }); it('redirects to /account/setup when user has no profile', async () => { - findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null }); + findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] }); const locals = createLocals(mockUser() as any); const url = new URL('http://localhost/dashboard'); @@ -53,7 +53,7 @@ describe('(protected) layout server load', () => { }); it('skips redirect when already on /account/setup', async () => { - findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null }); + findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] }); const locals = createLocals(mockUser() as any); const url = new URL('http://localhost/account/setup'); diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 3b9e8b5..f7fad27 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -1,5 +1,6 @@ import type { Actions } from './$types'; import { auth } from '$lib/auth'; +import { prisma } from '$lib/db'; import { fail } from '@sveltejs/kit'; export const actions: Actions = { @@ -8,7 +9,8 @@ export const actions: Actions = { const firstName = data.get('firstName') as string; const lastName = data.get('lastName') as string; - const email = data.get('email') as string; + const rawEmail = data.get('email') as string; + const email = rawEmail?.trim().toLowerCase() ?? ''; const password = data.get('password') as string; const password2 = data.get('password2') as string; @@ -25,6 +27,22 @@ export const actions: Actions = { } }); + // Link any profile / license key that was created for this person + // before they had an account (matched by email). + const user = await prisma.user.findUnique({ where: { email } }); + if (user) { + await prisma.$transaction([ + prisma.profile.updateMany({ + where: { email, userId: null }, + data: { userId: user.id } + }), + prisma.licenseKey.updateMany({ + where: { email, userId: null }, + data: { userId: user.id } + }) + ]); + } + return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : 'An unexpected error occurred'; diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts index f20f03e..0eab753 100644 --- a/src/routes/register/page.server.spec.ts +++ b/src/routes/register/page.server.spec.ts @@ -2,12 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { isActionFailure } from '@sveltejs/kit'; import { createFormRequest } from '$lib/test-utils'; -const { signUpEmail } = vi.hoisted(() => ({ - signUpEmail: vi.fn() -})); +const { signUpEmail, userFindUnique, profileUpdateMany, licenseKeyUpdateMany, $transaction } = + vi.hoisted(() => ({ + signUpEmail: vi.fn(), + userFindUnique: vi.fn(), + profileUpdateMany: vi.fn(), + licenseKeyUpdateMany: vi.fn(), + $transaction: vi.fn((ops: Promise[]) => Promise.all(ops)) + })); vi.mock('$lib/db', () => ({ - prisma: {} + prisma: { + user: { findUnique: userFindUnique }, + profile: { updateMany: profileUpdateMany }, + licenseKey: { updateMany: licenseKeyUpdateMany }, + $transaction + } })); vi.mock('$lib/auth', () => ({ @@ -25,6 +35,10 @@ const invokeDefault = (request: Request) => actions.default({ request } as any); describe('register page server action', () => { beforeEach(() => { signUpEmail.mockReset(); + userFindUnique.mockReset(); + profileUpdateMany.mockReset(); + licenseKeyUpdateMany.mockReset(); + $transaction.mockClear(); }); it('returns fail(400) when passwords do not match', async () => { @@ -46,6 +60,7 @@ describe('register page server action', () => { it('returns success on successful signup', async () => { signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); + userFindUnique.mockResolvedValue(null); const request = createFormRequest({ firstName: 'Jane', @@ -66,6 +81,32 @@ describe('register page server action', () => { }); }); + it('links existing profile and license keys by email after signup', async () => { + const user = { id: 'user-1', email: 'jane@example.com' }; + signUpEmail.mockResolvedValue({ user }); + userFindUnique.mockResolvedValue(user); + + const request = createFormRequest({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'secret123', + password2: 'secret123' + }); + + const result = await invokeDefault(request); + expect(result).toEqual({ success: true }); + expect(profileUpdateMany).toHaveBeenCalledWith({ + where: { email: 'jane@example.com', userId: null }, + data: { userId: 'user-1' } + }); + expect(licenseKeyUpdateMany).toHaveBeenCalledWith({ + where: { email: 'jane@example.com', userId: null }, + data: { userId: 'user-1' } + }); + expect($transaction).toHaveBeenCalledTimes(1); + }); + it('returns fail(400) with error message when signUpEmail throws', async () => { signUpEmail.mockRejectedValue(new Error('email already in use'));