Restructure route organization
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error, fail, type Actions } from '@sveltejs/kit';
|
||||
import { serverFormatPhoneNumber } from '$lib/utils';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: locals.user.id },
|
||||
include: {
|
||||
profile: {
|
||||
include: { addresses: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
return { user };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
const firstName = data.get('firstName') as string;
|
||||
const lastName = data.get('lastName') as string;
|
||||
const phoneNumber = serverFormatPhoneNumber(data.get('phoneNumber') as string);
|
||||
|
||||
if (!firstName || !lastName || !phoneNumber) {
|
||||
return fail(400, { error: 'All fields are required.', action: 'updateProfile' });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.profile.update({
|
||||
where: { userId: locals.user.id },
|
||||
data: { firstName, lastName, phoneNumber }
|
||||
});
|
||||
return { success: true, action: 'updateProfile' };
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
return fail(500, { error: 'Failed to update profile.', action: 'updateProfile' });
|
||||
}
|
||||
},
|
||||
|
||||
addAddress: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
const street = data.get('street') as string;
|
||||
const city = data.get('city') as string;
|
||||
const state = data.get('state') as string;
|
||||
const postalCode = data.get('postalCode') as string;
|
||||
const country = data.get('country') as string;
|
||||
|
||||
if (!street || !city || !state || !postalCode || !country) {
|
||||
return fail(400, { error: 'All address fields are required.', action: 'addAddress' });
|
||||
}
|
||||
|
||||
const profile = await prisma.profile.findUnique({
|
||||
where: { userId: locals.user.id },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw error(400, 'Profile not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.address.create({
|
||||
data: {
|
||||
profileId: profile.id,
|
||||
street,
|
||||
city,
|
||||
state,
|
||||
postalCode,
|
||||
country
|
||||
}
|
||||
});
|
||||
return { success: true, action: 'addAddress' };
|
||||
} catch (err) {
|
||||
console.error('Address create error:', err);
|
||||
return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import { clientFormatPhoneNumber } from '$lib/utils';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
let initialPhone = $derived(clientFormatPhoneNumber(data.user.profile?.phoneNumber ?? ''));
|
||||
let phoneNumber = $state('');
|
||||
let phoneInitialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!phoneInitialized) {
|
||||
phoneNumber = initialPhone;
|
||||
phoneInitialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
let showAddressForm = $state(false);
|
||||
|
||||
function handlePhoneInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
phoneNumber = clientFormatPhoneNumber(input.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-8 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Account</h1>
|
||||
|
||||
<!-- Email (read-only) -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">Email</p>
|
||||
<p class="mt-1 text-gray-900">{data.user.email}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Form -->
|
||||
<section>
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-900">Profile</h2>
|
||||
|
||||
{#if form?.action === 'updateProfile' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
Profile updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'updateProfile' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-sm font-medium text-gray-700">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={data.user.profile?.firstName ?? ''}
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-sm font-medium text-gray-700">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={data.user.profile?.lastName ?? ''}
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phoneNumber"
|
||||
name="phoneNumber"
|
||||
bind:value={phoneNumber}
|
||||
oninput={handlePhoneInput}
|
||||
required
|
||||
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Addresses -->
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium text-gray-900">Addresses</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
onclick={() => (showAddressForm = !showAddressForm)}
|
||||
>
|
||||
{showAddressForm ? 'Cancel' : 'Add Address'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.action === 'addAddress' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
Address added successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'addAddress' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAddressForm}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addAddress"
|
||||
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label for="street" class="block text-sm font-medium text-gray-700">Street</label>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="postalCode" class="block text-sm font-medium text-gray-700">
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
required
|
||||
value="US"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.user.profile.addresses as address}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-sm text-gray-900">{address.street}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{address.city}, {address.state} {address.postalCode}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">{address.country}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !showAddressForm}
|
||||
<p class="text-sm text-gray-500">No addresses on file.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError, isActionFailure } from '@sveltejs/kit';
|
||||
import {
|
||||
createLocals,
|
||||
createFormRequest,
|
||||
mockUser,
|
||||
mockUserWithProfileAndAddresses
|
||||
} from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
profile: { update: vi.fn(), findUnique: vi.fn() },
|
||||
address: { create: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
|
||||
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
|
||||
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
|
||||
|
||||
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
|
||||
(actions as any)[name]({ request, locals } as any);
|
||||
|
||||
describe('(protected)/account page server', () => {
|
||||
beforeEach(() => {
|
||||
userFindUnique.mockReset();
|
||||
profileUpdate.mockReset();
|
||||
profileFindUnique.mockReset();
|
||||
addressCreate.mockReset();
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws 403 when user is not found in the database', async () => {
|
||||
userFindUnique.mockResolvedValue(null);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns user with profile and addresses', async () => {
|
||||
const user = mockUserWithProfileAndAddresses();
|
||||
userFindUnique.mockResolvedValue(user);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.user).toEqual(user);
|
||||
expect(result.user.profile.addresses).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes addresses in the query', async () => {
|
||||
userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses());
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
await load({ locals } as any);
|
||||
|
||||
expect(userFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
include: { profile: { include: { addresses: true } } }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfile action', () => {
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '(555) 010-0100'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('updateProfile', request, createLocals())
|
||||
).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns fail(400) when firstName is missing', async () => {
|
||||
const request = createFormRequest({
|
||||
firstName: '',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '(555) 010-0100'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('updateProfile', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
expect((result as any).data.error).toBe('All fields are required.');
|
||||
});
|
||||
|
||||
it('returns fail(400) when lastName is missing', async () => {
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: '',
|
||||
phoneNumber: '(555) 010-0100'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('updateProfile', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns fail(400) when phoneNumber is missing', async () => {
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: ''
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('updateProfile', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates profile with server-formatted phone number on success', async () => {
|
||||
profileUpdate.mockResolvedValue({});
|
||||
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
phoneNumber: '(817) 555-1234'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('updateProfile', request, locals);
|
||||
expect(result).toEqual({ success: true, action: 'updateProfile' });
|
||||
expect(profileUpdate).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'));
|
||||
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '(555) 010-0100'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('updateProfile', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
expect((result as any).status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAddress action', () => {
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const request = createFormRequest({
|
||||
street: '123 Main St',
|
||||
city: 'Dallas',
|
||||
state: 'TX',
|
||||
postalCode: '75001',
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, createLocals())
|
||||
).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns fail(400) when a required field is missing', async () => {
|
||||
const request = createFormRequest({
|
||||
street: '123 Main St',
|
||||
city: '',
|
||||
state: 'TX',
|
||||
postalCode: '75001',
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('addAddress', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
expect((result as any).data.error).toBe('All address fields are required.');
|
||||
});
|
||||
|
||||
it('throws 400 when profile is not found', async () => {
|
||||
profileFindUnique.mockResolvedValue(null);
|
||||
|
||||
const request = createFormRequest({
|
||||
street: '123 Main St',
|
||||
city: 'Dallas',
|
||||
state: 'TX',
|
||||
postalCode: '75001',
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, locals)
|
||||
).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 400)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates address linked to profile on success', async () => {
|
||||
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
|
||||
addressCreate.mockResolvedValue({});
|
||||
|
||||
const request = createFormRequest({
|
||||
street: '456 Oak Ave',
|
||||
city: 'Austin',
|
||||
state: 'TX',
|
||||
postalCode: '73301',
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('addAddress', request, locals);
|
||||
expect(result).toEqual({ success: true, action: 'addAddress' });
|
||||
expect(addressCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
profileId: 'profile-1',
|
||||
street: '456 Oak Ave',
|
||||
city: 'Austin',
|
||||
state: 'TX',
|
||||
postalCode: '73301',
|
||||
country: 'US'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fail(500) when prisma create throws', async () => {
|
||||
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
|
||||
addressCreate.mockRejectedValue(new Error('db error'));
|
||||
|
||||
const request = createFormRequest({
|
||||
street: '123 Main St',
|
||||
city: 'Dallas',
|
||||
state: 'TX',
|
||||
postalCode: '75001',
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await invokeAction('addAddress', request, locals);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
expect((result as any).status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const profile = await prisma.profile.findUnique({
|
||||
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 activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
|
||||
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
|
||||
|
||||
return {
|
||||
firstName: profile?.firstName ?? 'User',
|
||||
activeKeys,
|
||||
totalKeys,
|
||||
proclamationCount
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Welcome, {data.firstName}!</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="/license-keys"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Active License Keys</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{data.activeKeys}</p>
|
||||
<p class="mt-1 text-sm text-gray-400">{data.totalKeys} total</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/proclamations"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Proclamations</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{data.proclamationCount}</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Browse all</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Account</p>
|
||||
<p class="mt-2 text-lg font-semibold text-gray-900">Manage Profile</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Edit your details</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { createLocals, mockUser } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
profile: { findUnique: vi.fn() },
|
||||
licenseKey: { groupBy: vi.fn() },
|
||||
proclamation: { count: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
|
||||
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
|
||||
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/dashboard page server load', () => {
|
||||
beforeEach(() => {
|
||||
profileFindUnique.mockReset();
|
||||
licenseKeyGroupBy.mockReset();
|
||||
proclamationCount.mockReset();
|
||||
});
|
||||
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns firstName from profile', async () => {
|
||||
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
|
||||
licenseKeyGroupBy.mockResolvedValue([]);
|
||||
proclamationCount.mockResolvedValue(0);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.firstName).toBe('Jane');
|
||||
});
|
||||
|
||||
it('defaults firstName to "User" when profile is null', async () => {
|
||||
profileFindUnique.mockResolvedValue(null);
|
||||
licenseKeyGroupBy.mockResolvedValue([]);
|
||||
proclamationCount.mockResolvedValue(0);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.firstName).toBe('User');
|
||||
});
|
||||
|
||||
it('computes activeKeys and totalKeys from groupBy results', async () => {
|
||||
profileFindUnique.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;
|
||||
expect(result.activeKeys).toBe(3);
|
||||
expect(result.totalKeys).toBe(5);
|
||||
});
|
||||
|
||||
it('returns 0 for activeKeys when none are active', async () => {
|
||||
profileFindUnique.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const licenseKeys = await prisma.licenseKey.findMany({
|
||||
where: { userId: locals.user.id },
|
||||
orderBy: { key: 'asc' }
|
||||
});
|
||||
|
||||
return { licenseKeys };
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">License Keys</h1>
|
||||
|
||||
{#if data.licenseKeys.length > 0}
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Level
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.licenseKeys as key}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900">
|
||||
{key.key}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700">
|
||||
{key.level}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{key.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<p class="text-lg text-gray-500">You don't have any license keys yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
licenseKey: { findMany: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const findMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/license-keys page server load', () => {
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns license keys for the authenticated user', async () => {
|
||||
const keys = [
|
||||
mockLicenseKey(),
|
||||
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
|
||||
];
|
||||
findMany.mockResolvedValue(keys);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.licenseKeys).toEqual(keys);
|
||||
});
|
||||
|
||||
it('passes userId in the where clause', async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
await load({ locals } as any);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
orderBy: { key: 'asc' }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when user has no keys', async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.licenseKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
|
||||
if (!category) return '';
|
||||
const name = String(category.name ?? '');
|
||||
const parent = category.parent as Record<string, unknown> | undefined;
|
||||
if (parent?.name) return `${String(parent.name)}/${name}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const search = url.searchParams.get('search') ?? '';
|
||||
const categoryId = url.searchParams.get('category');
|
||||
const where: Record<string, unknown> = {};
|
||||
if (search) {
|
||||
where.name = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
if (categoryId) {
|
||||
where.categoryId = parseInt(categoryId, 10);
|
||||
}
|
||||
|
||||
const [proclamations, categories] = await Promise.all([
|
||||
prisma.proclamation.findMany({
|
||||
where,
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
|
||||
]);
|
||||
|
||||
const normalizedProclamations = proclamations.map((p) => {
|
||||
if (p.category) {
|
||||
(p.category as Record<string, unknown>).displayName = getCategoryDisplayName(
|
||||
p.category as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const normalizedCategories = categories.map((c) => ({
|
||||
...c,
|
||||
displayName: getCategoryDisplayName(c)
|
||||
}));
|
||||
|
||||
return {
|
||||
proclamations: normalizedProclamations,
|
||||
categories: normalizedCategories,
|
||||
search,
|
||||
categoryId: categoryId ? parseInt(categoryId, 10) : null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
function displayCategoryName(category: { name: string; displayName?: string }) {
|
||||
return category.displayName ?? category.name;
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let categoryId: number | null = $state(null);
|
||||
let initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!initialized) {
|
||||
search = data.search;
|
||||
categoryId = data.categoryId;
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (categoryId) params.set('category', String(categoryId));
|
||||
goto(`/proclamations?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search = '';
|
||||
categoryId = null;
|
||||
goto('/proclamations', { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Proclamations</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
bind:value={search}
|
||||
placeholder="Search by name..."
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
|
||||
<select
|
||||
id="category"
|
||||
bind:value={categoryId}
|
||||
onchange={applyFilters}
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value={null}>All Categories</option>
|
||||
{#each data.categories as cat}
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
onclick={applyFilters}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{#if data.search || data.categoryId}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if data.proclamations.length > 0}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.proclamations as proclamation}
|
||||
<a
|
||||
href="/proclamations/{proclamation.id}"
|
||||
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">{displayCategoryName(proclamation.category)}</p>
|
||||
<div class="mt-auto flex items-center justify-between pt-4">
|
||||
<span class="text-sm text-purple-600">
|
||||
{proclamation.solfeggioFrequency.value} Hz —
|
||||
{proclamation.solfeggioFrequency.name}
|
||||
</span>
|
||||
<span class="font-semibold text-gray-900">
|
||||
${proclamation.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<p class="text-lg text-gray-500">No proclamations found.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const parentData = typeof parent === 'function' ? await parent() : {};
|
||||
const userRoleId = (parentData as { userRoleId?: number }).userRoleId ?? null;
|
||||
if (userRoleId != null && !canAccessAdmin(userRoleId)) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const proclamation = await prisma.proclamation.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!proclamation) {
|
||||
throw error(404, 'Proclamation not found');
|
||||
}
|
||||
|
||||
return { proclamation };
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const categoryBreadcrumb = $derived(
|
||||
data.proclamation.category.parent
|
||||
? `${data.proclamation.category.parent.name} / ${data.proclamation.category.name}`
|
||||
: data.proclamation.category.name
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6">
|
||||
<a
|
||||
href="/proclamations"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
← Back to Proclamations
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{data.proclamation.name}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{categoryBreadcrumb}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2">
|
||||
<p class="text-xs font-medium text-purple-600">Frequency</p>
|
||||
<p class="text-sm font-semibold text-purple-900">
|
||||
{data.proclamation.solfeggioFrequency.value} Hz —
|
||||
{data.proclamation.solfeggioFrequency.name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<p class="text-xs font-medium text-gray-500">Price</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
${data.proclamation.price.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6">
|
||||
{@html data.proclamation.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { mockProclamation } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamation: { findUnique: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const findUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
});
|
||||
|
||||
it('throws 404 when proclamation is not found', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
|
||||
const err = await Promise.resolve(
|
||||
load({ params: { id: 'nonexistent' } } as any)
|
||||
).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 404)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns proclamation when found', async () => {
|
||||
const proclamation = mockProclamation();
|
||||
findUnique.mockResolvedValue(proclamation);
|
||||
|
||||
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
|
||||
expect(result.proclamation).toEqual(proclamation);
|
||||
});
|
||||
|
||||
it('queries by params.id with frequency and category (including parent)', async () => {
|
||||
findUnique.mockResolvedValue(mockProclamation());
|
||||
|
||||
await load({ params: { id: 'proc-1' } } as any);
|
||||
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'proc-1' },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns proclamation with parent category when present', async () => {
|
||||
const proclamation = mockProclamation({
|
||||
category: {
|
||||
id: 2,
|
||||
name: 'Inner Healing',
|
||||
parentId: 1,
|
||||
parent: { id: 1, name: 'Healing', parentId: null }
|
||||
}
|
||||
});
|
||||
findUnique.mockResolvedValue(proclamation);
|
||||
|
||||
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
|
||||
expect(result.proclamation.category.parent.name).toBe('Healing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mockProclamation, mockCategory, createUrl } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamation: { findMany: vi.fn() },
|
||||
proclamationCategory: { findMany: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
beforeEach(() => {
|
||||
proclamationFindMany.mockReset();
|
||||
categoryFindMany.mockReset();
|
||||
});
|
||||
|
||||
it('returns proclamations and categories with no filters', async () => {
|
||||
const proclamations = [mockProclamation()];
|
||||
const categories = [mockCategory()];
|
||||
proclamationFindMany.mockResolvedValue(proclamations);
|
||||
categoryFindMany.mockResolvedValue(categories);
|
||||
|
||||
const url = createUrl('/admin/proclamations');
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.proclamations).toEqual(proclamations);
|
||||
expect(result.categories[0]).toEqual(expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
}));
|
||||
expect(result.search).toBe('');
|
||||
expect(result.categoryId).toBeNull();
|
||||
});
|
||||
|
||||
it('passes search filter to findMany when search param is present', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations', { search: 'healing' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { name: { contains: 'healing', mode: 'insensitive' } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns search string from URL params', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations', { search: 'peace' });
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.search).toBe('peace');
|
||||
});
|
||||
|
||||
it('passes categoryId filter when category param is present', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations', { category: '3' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { categoryId: 3 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns parsed categoryId from URL params', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations', { category: '5' });
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.categoryId).toBe(5);
|
||||
});
|
||||
|
||||
it('combines search and category filters', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations', { search: 'love', category: '2' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
name: { contains: 'love', mode: 'insensitive' },
|
||||
categoryId: 2
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes solfeggioFrequency and category relations', async () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/admin/proclamations');
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user