Build user pages
This commit is contained in:
@@ -1,48 +1,66 @@
|
||||
<!-- This layout is used for all pages where a user must be authenticated to visit.
|
||||
Below the navigation bar, there should be two sections. A sidebar, containing links
|
||||
to common "pages", and a window to the right of that sidebar, with it's contents expanded
|
||||
to fill the entire area, to display the current "page".
|
||||
This layout also acts as a sort of 'gatekeeper' in that it is also the authorization layer.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children, data } = $props<{ children: any; data: LayoutData }>();
|
||||
|
||||
let adminExpanded = $state(true);
|
||||
|
||||
const isAdmin = $derived(canAccessAdmin(data.userRoleId));
|
||||
const adminModels = getAllModelConfigs();
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', label: 'Dashboard' },
|
||||
{ href: '/account', label: 'Account' },
|
||||
{ href: '/proclamations', label: 'Proclamations' },
|
||||
{ href: '/license-keys', label: 'License Keys' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Main Area -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Sidebar -->
|
||||
<div class="mt-12 mr-12 flex flex-col gap-1">
|
||||
<!-- Sidebar links -->
|
||||
<a href="/dashboard" class="text-blue-600">Dashboard</a>
|
||||
<nav class="mt-12 mr-12 flex flex-col gap-1">
|
||||
{#each navLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if isAdmin}
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div class="mt-4 flex flex-col gap-1">
|
||||
<a
|
||||
href="/admin"
|
||||
class="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
>
|
||||
Admin Panel
|
||||
</a>
|
||||
<div class="ml-5 flex flex-col">
|
||||
<div class="ml-3 flex flex-col gap-0.5">
|
||||
{#each adminModels as model}
|
||||
<a href="/admin/{model.slug}" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
<a
|
||||
href="/admin/{model.slug}"
|
||||
class="rounded px-3 py-1 text-sm transition-colors {isActive(
|
||||
'/admin/' + model.slug
|
||||
)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800'}"
|
||||
>
|
||||
{model.displayName}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- End Sidebar links -->
|
||||
</div>
|
||||
<!-- End Sidebar -->
|
||||
</nav>
|
||||
<!-- Page content -->
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
|
||||
@@ -1,14 +1,96 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
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: true }
|
||||
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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,220 @@
|
||||
<script lang="ts">
|
||||
import type {PageProps} from "./$types";
|
||||
let {data}: PageProps = $props();
|
||||
$inspect(data);
|
||||
</script>
|
||||
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>
|
||||
|
||||
@@ -1,46 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { createLocals, mockUser, mockUserWithProfile } from '$lib/test-utils';
|
||||
import { isHttpError, isActionFailure } from '@sveltejs/kit';
|
||||
import {
|
||||
createLocals,
|
||||
createFormRequest,
|
||||
mockUser,
|
||||
mockUserWithProfileAndAddresses
|
||||
} from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn()
|
||||
}
|
||||
user: { findUnique: vi.fn() },
|
||||
profile: { update: vi.fn(), findUnique: vi.fn() },
|
||||
address: { create: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+page.server';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
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>;
|
||||
|
||||
describe('(protected)/account page server load', () => {
|
||||
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
|
||||
(actions as any)[name]({ request, locals } as any);
|
||||
|
||||
describe('(protected)/account page server', () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
userFindUnique.mockReset();
|
||||
profileUpdate.mockReset();
|
||||
profileFindUnique.mockReset();
|
||||
addressCreate.mockReset();
|
||||
});
|
||||
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
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);
|
||||
});
|
||||
|
||||
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 } } }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 when user is not found in the database', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
describe('updateProfile action', () => {
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
const request = createFormRequest({
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '(555) 010-0100'
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns user with profile when found', async () => {
|
||||
const user = mockUserWithProfile();
|
||||
findUnique.mockResolvedValue(user);
|
||||
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 locals = createLocals(mockUser() as any);
|
||||
const result = (await load({ locals } as any)) as any;
|
||||
expect(result.user).toEqual(user);
|
||||
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,104 @@
|
||||
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: {
|
||||
user: {
|
||||
findUnique: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load } from './+layout.server';
|
||||
|
||||
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('admin layout server load', () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
});
|
||||
|
||||
it('throws 403 when user is not found', async () => {
|
||||
findUnique.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('throws 403 when roleId is null', async () => {
|
||||
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: null, role: 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('throws 403 when roleId is 0', async () => {
|
||||
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: 0, role: 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('throws 403 when roleId is USER (4)', async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
name: 'Jane',
|
||||
roleId: 4,
|
||||
role: { name: 'User' }
|
||||
});
|
||||
|
||||
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('allows access for SUPER (roleId 1)', async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
name: 'Jane',
|
||||
roleId: 1,
|
||||
role: { name: 'Super' }
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await load({ locals } as any);
|
||||
expect(result).toEqual({
|
||||
adminUser: { id: 'user-1', name: 'Jane', roleId: 1, roleName: 'Super' }
|
||||
});
|
||||
});
|
||||
|
||||
it('allows access for ADMIN (roleId 2)', async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
name: 'Jane',
|
||||
roleId: 2,
|
||||
role: { name: 'Admin' }
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await load({ locals } as any);
|
||||
expect(result).toEqual({
|
||||
adminUser: { id: 'user-1', name: 'Jane', roleId: 2, roleName: 'Admin' }
|
||||
});
|
||||
});
|
||||
|
||||
it('allows access for STAFF (roleId 3)', async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
name: 'Jane',
|
||||
roleId: 3,
|
||||
role: { name: 'Staff' }
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await load({ locals } as any);
|
||||
expect(result).toEqual({
|
||||
adminUser: { id: 'user-1', name: 'Jane', roleId: 3, roleName: 'Staff' }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: locals.user.id },
|
||||
select: { id: true, name: true, role: true }
|
||||
});
|
||||
const affirmations = await prisma.affirmation.findMany({
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
}
|
||||
});
|
||||
const affirmationCategories = await prisma.affirmationCategory.findMany({
|
||||
include: { children: true }
|
||||
});
|
||||
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
|
||||
return { user, affirmationCategories, affirmations, solfeggioFrequencies };
|
||||
};
|
||||
|
||||
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
|
||||
let parent = await prisma.affirmationCategory.findFirst({
|
||||
where: { name: categoryName, parentId: null }
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
parent = await prisma.affirmationCategory.create({
|
||||
data: { name: categoryName }
|
||||
});
|
||||
}
|
||||
|
||||
if (!subCategoryName) return parent;
|
||||
|
||||
let child = await prisma.affirmationCategory.findFirst({
|
||||
where: { name: subCategoryName, parentId: parent.id }
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
child = await prisma.affirmationCategory.create({
|
||||
data: { name: subCategoryName, parentId: parent.id }
|
||||
});
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request }: RequestEvent) => {
|
||||
const form = await request.formData();
|
||||
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
|
||||
const price = Number(form.get('price')) || 0.0;
|
||||
const categoryName = form.get('categoryName') as string;
|
||||
const subCategoryName = (form.get('subCategoryName') as string) || null;
|
||||
|
||||
if (frequencyId === 0) {
|
||||
return fail(400, {
|
||||
error: 'Solfeggio Frequency not selected!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
if (!categoryName) {
|
||||
return fail(400, {
|
||||
error: 'Category is required!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
const category = await resolveCategory(categoryName, subCategoryName);
|
||||
|
||||
try {
|
||||
const newAffirmation = await prisma.affirmation.create({
|
||||
data: {
|
||||
name: form.get('name') as string,
|
||||
content: form.get('content') as string,
|
||||
solfeggioFrequencyId: frequencyId,
|
||||
categoryId: category.id,
|
||||
price: price
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
solfeggioFrequency: true
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, newAffirmation };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return fail(500, {
|
||||
error: 'An unknown error has occurred. Please try again later.',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
|
||||
import Center from '$lib/components/containers/Center.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import AffirmationList from '$lib/components/ui/AffirmationList/AffirmationList.svelte';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
let showModal = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
showModal = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
|
||||
<div class="flex shrink-0 items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Affirmations</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
|
||||
onclick={() => (showModal = true)}
|
||||
>
|
||||
Create +
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if data.affirmations.length > 0}
|
||||
<AffirmationList affirmations={data.affirmations} />
|
||||
{:else}
|
||||
<Center>
|
||||
<div class="text-center">
|
||||
<p class="text-lg text-gray-500">No affirmations yet</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
|
||||
</div>
|
||||
</Center>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModal}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Create New Affirmation</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onclick={() => (showModal = false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AffirmationForm
|
||||
categories={data.affirmationCategories}
|
||||
frequencies={data.solfeggioFrequencies}
|
||||
{form}
|
||||
action="?/create"
|
||||
submitLabel="Create Affirmation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
|
||||
const affirmation = await prisma.affirmation.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
|
||||
});
|
||||
|
||||
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
|
||||
const affirmationCategories = await prisma.affirmationCategory.findMany({
|
||||
include: { children: true }
|
||||
});
|
||||
|
||||
if (!affirmation) {
|
||||
throw error(404, 'not found');
|
||||
}
|
||||
|
||||
return { affirmation, solfeggioFrequencies, affirmationCategories };
|
||||
};
|
||||
|
||||
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
|
||||
let parent = await prisma.affirmationCategory.findFirst({
|
||||
where: { name: categoryName, parentId: null }
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
parent = await prisma.affirmationCategory.create({
|
||||
data: { name: categoryName }
|
||||
});
|
||||
}
|
||||
|
||||
if (!subCategoryName) return parent;
|
||||
|
||||
let child = await prisma.affirmationCategory.findFirst({
|
||||
where: { name: subCategoryName, parentId: parent.id }
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
child = await prisma.affirmationCategory.create({
|
||||
data: { name: subCategoryName, parentId: parent.id }
|
||||
});
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request }: RequestEvent) => {
|
||||
const form = await request.formData();
|
||||
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
|
||||
const price = Number(form.get('price')) || 0.0;
|
||||
const categoryName = form.get('categoryName') as string;
|
||||
const subCategoryName = (form.get('subCategoryName') as string) || null;
|
||||
|
||||
if (!categoryName) {
|
||||
return fail(400, {
|
||||
error: 'Category is required!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
if (frequencyId === 0) {
|
||||
return fail(400, {
|
||||
error: 'Solfeggio Frequency not selected!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
const category = await resolveCategory(categoryName, subCategoryName);
|
||||
|
||||
try {
|
||||
const updatedAffirmation = await prisma.affirmation.update({
|
||||
where: {
|
||||
id: form.get('id') as string
|
||||
},
|
||||
data: {
|
||||
name: form.get('name') as string,
|
||||
content: form.get('content') as string,
|
||||
solfeggioFrequencyId: frequencyId,
|
||||
categoryId: category.id,
|
||||
price: price
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
solfeggioFrequency: true
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, updatedAffirmation };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return fail(500, {
|
||||
error:
|
||||
'An unknown error has occurred. Please try again later. Your changes have not been saved.',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
let seconds = $state(0);
|
||||
let remainder = $derived(5 - seconds);
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (form?.success) {
|
||||
seconds += 1;
|
||||
if (seconds === 6) {
|
||||
window.location.href = '/affirmations';
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
|
||||
<a href="/affirmations" class="text-blue-600">Back to Affirmations</a>
|
||||
|
||||
{#if 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 form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
Affirmation updated successfully! You will be redirected in {remainder} seconds.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
<AffirmationForm
|
||||
affirmation={data.affirmation}
|
||||
categories={data.affirmationCategories}
|
||||
frequencies={data.solfeggioFrequencies}
|
||||
{form}
|
||||
action="?/update"
|
||||
submitLabel="Save Affirmation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,38 @@
|
||||
<script lang="ts">
|
||||
// This page should show a user's dashboard, not the admin.
|
||||
// There should be a dedicated 'admin/' page for admins
|
||||
// There should be another dedicated 'support/' page for staff.
|
||||
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="/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);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const frequencies = await prisma.solfeggioFrequency.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { affirmations: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return { frequencies };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request }: RequestEvent) => {
|
||||
const form = await request.formData();
|
||||
const name = form.get('name') as string;
|
||||
const value = Number(form.get('value'));
|
||||
|
||||
if (!name) {
|
||||
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
|
||||
}
|
||||
|
||||
if (!value || value <= 0) {
|
||||
return fail(400, {
|
||||
error: 'A valid frequency value is required!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const newFrequency = await prisma.solfeggioFrequency.create({
|
||||
data: { name, value }
|
||||
});
|
||||
|
||||
return { success: true, newFrequency };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return fail(500, {
|
||||
error: 'An unknown error has occurred. Please try again later.',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
|
||||
import Center from '$lib/components/containers/Center.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import FrequencyList from '$lib/components/ui/FrequencyList/FrequencyList.svelte';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
let showModal = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
showModal = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
|
||||
<div class="flex shrink-0 items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Frequencies</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
|
||||
onclick={() => (showModal = true)}
|
||||
>
|
||||
Create +
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if data.frequencies.length > 0}
|
||||
<FrequencyList frequencies={data.frequencies} />
|
||||
{:else}
|
||||
<Center>
|
||||
<div class="text-center">
|
||||
<p class="text-lg text-gray-500">No frequencies yet</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
|
||||
</div>
|
||||
</Center>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModal}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Create New Frequency</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onclick={() => (showModal = false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<FrequencyForm {form} action="?/create" submitLabel="Create Frequency" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
|
||||
const frequency = await prisma.solfeggioFrequency.findUnique({
|
||||
where: { id: Number(params.id) }
|
||||
});
|
||||
|
||||
if (!frequency) {
|
||||
throw error(404, 'not found');
|
||||
}
|
||||
|
||||
return { frequency };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request }: RequestEvent) => {
|
||||
const form = await request.formData();
|
||||
const id = Number(form.get('id'));
|
||||
const name = form.get('name') as string;
|
||||
const value = Number(form.get('value'));
|
||||
|
||||
if (!name) {
|
||||
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
|
||||
}
|
||||
|
||||
if (!value || value <= 0) {
|
||||
return fail(400, {
|
||||
error: 'A valid frequency value is required!',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedFrequency = await prisma.solfeggioFrequency.update({
|
||||
where: { id },
|
||||
data: { name, value }
|
||||
});
|
||||
|
||||
return { success: true, updatedFrequency };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return fail(500, {
|
||||
error: 'An unknown error has occurred. Please try again later. Your changes have not been saved.',
|
||||
...Object.fromEntries(form)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
let seconds = $state(0);
|
||||
let remainder = $derived(5 - seconds);
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (form?.success) {
|
||||
seconds += 1;
|
||||
if (seconds === 6) {
|
||||
window.location.href = '/frequencies';
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
|
||||
<a href="/frequencies" class="text-blue-600">Back to Frequencies</a>
|
||||
|
||||
{#if 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 form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
Frequency updated successfully! You will be redirected in {remainder} seconds.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
<FrequencyForm
|
||||
frequency={data.frequency}
|
||||
{form}
|
||||
action="?/update"
|
||||
submitLabel="Save Frequency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,38 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
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: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
proclamations,
|
||||
categories,
|
||||
search,
|
||||
categoryId: categoryId ? parseInt(categoryId, 10) : null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
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}>{cat.name}</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">{proclamation.category.name}</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,21 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
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)/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,113 @@
|
||||
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)/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('/proclamations');
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.proclamations).toEqual(proclamations);
|
||||
expect(result.categories).toEqual(categories);
|
||||
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('/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('/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('/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('/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('/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('/proclamations');
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { solfeggioFrequency: true, category: true }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user