Restructure route organization

This commit is contained in:
NeilClack
2026-05-03 20:59:43 -05:00
parent e563a66dc7
commit 3d1d713308
15 changed files with 0 additions and 0 deletions
@@ -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 &mdash;
{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"
>
&larr; 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 &mdash;
{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 } } }
})
);
});
});