Merge branch 'dev'
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
|
||||
|
||||
function parseInput(formData: FormData) {
|
||||
const startMinutes = Number(formData.get('startMinutes'));
|
||||
const endMinutes = Number(formData.get('endMinutes'));
|
||||
let proclamationIds: string[] = [];
|
||||
const raw = formData.get('proclamationIds');
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
} catch {
|
||||
proclamationIds = [];
|
||||
}
|
||||
}
|
||||
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
||||
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const [blocks, categories, groups, proclamations] = await Promise.all([
|
||||
listBlocks(locals.user.id),
|
||||
prisma.proclamationCategory.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
||||
}),
|
||||
prisma.proclamationGroup.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
||||
}),
|
||||
// Content is deliberately never selected: the portal only surfaces
|
||||
// title/category/group.
|
||||
prisma.proclamation.findMany({
|
||||
select: { id: true, name: true, categoryId: true, groupId: true },
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
]);
|
||||
|
||||
const blockViews = blocks.map((block) => ({
|
||||
id: block.id,
|
||||
startMinutes: block.startMinutes,
|
||||
endMinutes: block.endMinutes,
|
||||
dynamicAudio: block.dynamicAudio,
|
||||
proclamations: block.proclamations.map((entry) => ({
|
||||
id: entry.proclamation.id,
|
||||
name: entry.proclamation.name,
|
||||
categoryId: entry.proclamation.categoryId,
|
||||
groupId: entry.proclamation.groupId
|
||||
}))
|
||||
}));
|
||||
|
||||
return { blocks: blockViews, categories, groups, proclamations };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const input = parseInput(await request.formData());
|
||||
const result = await createBlock(locals.user.id, input);
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Block not found.' });
|
||||
const result = await updateBlock(locals.user.id, id, parseInput(formData));
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteBlock: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const id = (await request.formData()).get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Block not found.' });
|
||||
const result = await deleteBlock(locals.user.id, id);
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
|
||||
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state<'create' | 'edit'>('create');
|
||||
let editingId = $state<string | undefined>(undefined);
|
||||
let initialStart = $state(9 * 60);
|
||||
let initialEnd = $state(10 * 60);
|
||||
let initialIds = $state<string[]>([]);
|
||||
let initialDynamicAudio = $state(false);
|
||||
let successMessage = $state('');
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function openCreate(startMinutes: number, endMinutes: number) {
|
||||
modalMode = 'create';
|
||||
editingId = undefined;
|
||||
initialStart = startMinutes;
|
||||
initialEnd = endMinutes;
|
||||
initialIds = [];
|
||||
initialDynamicAudio = false;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(block: {
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: { id: string }[];
|
||||
}) {
|
||||
modalMode = 'edit';
|
||||
editingId = block.id;
|
||||
initialStart = block.startMinutes;
|
||||
initialEnd = block.endMinutes;
|
||||
initialIds = block.proclamations.map((proclamation) => proclamation.id);
|
||||
initialDynamicAudio = block.dynamicAudio;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
}
|
||||
|
||||
function handleSaved() {
|
||||
modalOpen = false;
|
||||
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => (successMessage = ''), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
|
||||
Schedule
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
|
||||
Build your daily listening schedule. Up to 4 blocks per day.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||||
>
|
||||
{data.blocks.length} of 4 blocks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if successMessage}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
|
||||
>
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
|
||||
</div>
|
||||
|
||||
<BlockModal
|
||||
open={modalOpen}
|
||||
mode={modalMode}
|
||||
blockId={editingId}
|
||||
initialStartMinutes={initialStart}
|
||||
initialEndMinutes={initialEnd}
|
||||
initialProclamationIds={initialIds}
|
||||
{initialDynamicAudio}
|
||||
categories={data.categories}
|
||||
groups={data.groups}
|
||||
proclamations={data.proclamations}
|
||||
existingBlocks={data.blocks}
|
||||
onClose={closeModal}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isActionFailure } from '@sveltejs/kit';
|
||||
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamationCategory: { findMany: vi.fn() },
|
||||
proclamationGroup: { findMany: vi.fn() },
|
||||
proclamation: { findMany: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/schedule', () => ({
|
||||
listBlocks: vi.fn(),
|
||||
createBlock: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
deleteBlock: vi.fn()
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
|
||||
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
||||
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
||||
|
||||
const locals = createLocals(mockUser() as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 2, name: 'Physical' }
|
||||
]);
|
||||
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 1, name: 'General' }
|
||||
]);
|
||||
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
|
||||
listBlocksMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('schedule load', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
|
||||
status: 403
|
||||
});
|
||||
});
|
||||
|
||||
it('never selects proclamation content', async () => {
|
||||
await load({ locals } as never);
|
||||
|
||||
expect(procFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: { id: true, name: true, categoryId: true, groupId: true }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('maps blocks to title/category/group only', async () => {
|
||||
listBlocksMock.mockResolvedValue([
|
||||
{
|
||||
id: 'b1',
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
dynamicAudio: true,
|
||||
proclamations: [
|
||||
{
|
||||
proclamation: {
|
||||
id: 'p1',
|
||||
name: 'Peace',
|
||||
categoryId: 2,
|
||||
groupId: 1,
|
||||
content: '<p>secret</p>'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
const result = (await load({ locals } as never)) as {
|
||||
blocks: {
|
||||
id: string;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: Record<string, unknown>[];
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(result.blocks[0].dynamicAudio).toBe(true);
|
||||
expect(result.blocks[0].proclamations[0]).toEqual({
|
||||
id: 'p1',
|
||||
name: 'Peace',
|
||||
categoryId: 2,
|
||||
groupId: 1
|
||||
});
|
||||
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule actions', () => {
|
||||
it('createBlock rejects unauthenticated requests', async () => {
|
||||
await expect(
|
||||
actions.createBlock!({
|
||||
request: createFormRequest({}),
|
||||
locals: createLocals()
|
||||
} as never)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('createBlock surfaces the service error', async () => {
|
||||
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
|
||||
|
||||
const result = await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: '[]'
|
||||
}),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
if (isActionFailure(result)) {
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
|
||||
}
|
||||
});
|
||||
|
||||
it('createBlock parses the payload and succeeds', async () => {
|
||||
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
||||
|
||||
const result = await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1', 'p2']),
|
||||
dynamicAudio: 'true'
|
||||
}),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
||||
startMinutes: 360,
|
||||
endMinutes: 480,
|
||||
proclamationIds: ['p1', 'p2'],
|
||||
dynamicAudio: true
|
||||
});
|
||||
});
|
||||
|
||||
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
|
||||
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
||||
|
||||
await actions.createBlock!({
|
||||
request: createFormRequest({
|
||||
startMinutes: '360',
|
||||
endMinutes: '480',
|
||||
proclamationIds: JSON.stringify(['p1'])
|
||||
}),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(createBlockMock).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ dynamicAudio: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteBlock returns not found without an id', async () => {
|
||||
const result = await actions.deleteBlock!({
|
||||
request: createFormRequest({}),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
if (isActionFailure(result)) {
|
||||
expect(result.data).toEqual({ error: 'Block not found.' });
|
||||
}
|
||||
expect(deleteBlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,12 @@
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/schedule')}
|
||||
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/schedule')}`}
|
||||
>
|
||||
Schedule
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/profile')}
|
||||
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
|
||||
@@ -383,6 +389,13 @@
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/schedule')}
|
||||
onclick={closeMenu}
|
||||
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/schedule')}`}
|
||||
>
|
||||
Schedule
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/profile')}
|
||||
onclick={closeMenu}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
|
||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||
import { listBlocks } from '$lib/server/schedule';
|
||||
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
|
||||
* `email` + `licenseKey` query parameters and validates only that the pair
|
||||
* belong together — license active/entitlement is deliberately not checked
|
||||
* here, since an invalid license would already lock the kiosk out.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const ip = clientIp(request);
|
||||
const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 });
|
||||
if (!rl.ok) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
const email = (url.searchParams.get('email') ?? '').trim().toLowerCase();
|
||||
const licenseKey = (
|
||||
url.searchParams.get('licenseKey') ??
|
||||
url.searchParams.get('key') ??
|
||||
''
|
||||
).trim();
|
||||
|
||||
if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
|
||||
if (!storedKey) {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
// Licenses created before accounts were linked may carry only an email.
|
||||
const belongs =
|
||||
storedKey.userId === user.id ||
|
||||
(storedKey.userId === null && storedKey.email?.toLowerCase() === email);
|
||||
if (!belongs) {
|
||||
throw error(400, "this key doesn't appear to belong to you");
|
||||
}
|
||||
|
||||
const blocks = await listBlocks(user.id);
|
||||
|
||||
const serialized = blocks.map((block) =>
|
||||
serializeScheduleBlock(
|
||||
block,
|
||||
block.proclamations.map((entry) => entry.proclamation.id)
|
||||
)
|
||||
);
|
||||
|
||||
return jsonOk({ email, blocks: serialized });
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
||||
block: { findMany: vi.fn() },
|
||||
licenseKey: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { GET } from './+server';
|
||||
|
||||
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
function call(params: Record<string, string>) {
|
||||
const url = new URL('http://localhost/api/v2/schedule');
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
const request = new Request(url);
|
||||
return GET({ request, url } as Parameters<typeof GET>[0]);
|
||||
}
|
||||
|
||||
const KEY = 'AAAA-BBBB-CCCC-DDDD';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_clearRateLimits();
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('GET /api/v2/schedule auth', () => {
|
||||
it('rejects a request missing parameters', async () => {
|
||||
await expect(call({})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
body: { message: 'not-authorized' }
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown license key', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||
status: 400,
|
||||
body: { message: 'not-authorized' }
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown user', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
||||
userFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||
status: 400,
|
||||
body: { message: 'not-authorized' }
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a key that does not belong to the email account', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
|
||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||
status: 400,
|
||||
body: { message: "this key doesn't appear to belong to you" }
|
||||
});
|
||||
});
|
||||
|
||||
it('rate limits after ten requests from the same IP', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue(null);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
await call({});
|
||||
} catch {
|
||||
// Expected: each early call rejects with a 400.
|
||||
}
|
||||
}
|
||||
|
||||
const response = await call({});
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v2/schedule success', () => {
|
||||
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({
|
||||
key: KEY,
|
||||
userId: 'user-1',
|
||||
email: null,
|
||||
active: false
|
||||
});
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
blockFindMany.mockResolvedValue([
|
||||
{
|
||||
id: 'block-1',
|
||||
startMinutes: 21 * 60,
|
||||
endMinutes: 9 * 60,
|
||||
dynamicAudio: true,
|
||||
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
|
||||
}
|
||||
]);
|
||||
|
||||
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
email: 'jane@example.com',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
startTime: '21:00',
|
||||
startMinutes: 1260,
|
||||
endTime: '09:00',
|
||||
endMinutes: 540,
|
||||
overnight: true,
|
||||
durationMinutes: 720,
|
||||
dynamicAudio: true,
|
||||
proclamationIds: ['p1', 'p2']
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('returns IDs for every selected proclamation regardless of frequency', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
blockFindMany.mockResolvedValue([
|
||||
{
|
||||
id: 'block-1',
|
||||
startMinutes: 0,
|
||||
endMinutes: 60,
|
||||
proclamations: [{ proclamation: { id: 'p1' } }]
|
||||
}
|
||||
]);
|
||||
|
||||
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
|
||||
const body = (await response.json()) as {
|
||||
blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[];
|
||||
};
|
||||
|
||||
expect(body.blocks[0]).toEqual({
|
||||
id: 'block-1',
|
||||
startTime: '00:00',
|
||||
startMinutes: 0,
|
||||
endTime: '01:00',
|
||||
endMinutes: 60,
|
||||
overnight: false,
|
||||
durationMinutes: 60,
|
||||
dynamicAudio: false,
|
||||
proclamationIds: ['p1']
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the legacy `key` query alias and an email-only license', async () => {
|
||||
licenseKeyFindUnique.mockResolvedValue({
|
||||
key: KEY,
|
||||
userId: null,
|
||||
email: 'jane@example.com'
|
||||
});
|
||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||
|
||||
const response = await call({ email: 'jane@example.com', key: KEY });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user