Dev #1
@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import {
|
||||
copySchedule,
|
||||
createBlock,
|
||||
deleteBlock,
|
||||
getScheduleForLicense,
|
||||
@@ -68,8 +69,23 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
})
|
||||
]);
|
||||
|
||||
// Which License Keys already have a (typed) Schedule — used by the Copy UI
|
||||
// to warn before overwriting a target.
|
||||
const typedSchedules = await prisma.schedule.findMany({
|
||||
where: { licenseKeyId: { in: licenses.map((l) => l.id) }, type: { not: null } },
|
||||
select: { licenseKeyId: true }
|
||||
});
|
||||
const scheduledIds = new Set(typedSchedules.map((s) => s.licenseKeyId));
|
||||
const licenseViews = licenses.map((l) => ({
|
||||
id: l.id,
|
||||
key: l.key,
|
||||
systemName: l.systemName,
|
||||
active: l.active,
|
||||
hasSchedule: scheduledIds.has(l.id)
|
||||
}));
|
||||
|
||||
const requested = url.searchParams.get('license');
|
||||
const selected = licenses.find((l) => l.id === requested) ?? licenses[0] ?? null;
|
||||
const selected = licenseViews.find((l) => l.id === requested) ?? licenseViews[0] ?? null;
|
||||
|
||||
let type: 'daily' | 'weekly' | null = null;
|
||||
let blocks: {
|
||||
@@ -102,7 +118,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
licenses,
|
||||
licenses: licenseViews,
|
||||
selectedLicenseId: selected?.id ?? null,
|
||||
type,
|
||||
blocks,
|
||||
@@ -167,5 +183,34 @@ export const actions: Actions = {
|
||||
const result = await deleteBlock(licenseKeyId, id);
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
copy: async ({ request, locals }) => {
|
||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||
const formData = await request.formData();
|
||||
const sourceId = formData.get('sourceLicenseKeyId')?.toString() ?? '';
|
||||
const targetIds = formData
|
||||
.getAll('targetIds')
|
||||
.map((v) => v.toString())
|
||||
.filter((id) => id.length > 0);
|
||||
|
||||
// Only the caller's own systems are eligible; ownership is enforced by
|
||||
// scoping the lookup to the session user.
|
||||
const owned = await prisma.licenseKey.findMany({
|
||||
where: { userId: locals.user.id },
|
||||
select: { id: true, systemName: true, key: true }
|
||||
});
|
||||
if (!owned.some((l) => l.id === sourceId)) {
|
||||
return fail(404, { error: 'System not found.' });
|
||||
}
|
||||
|
||||
const result = await copySchedule(owned, sourceId, targetIds);
|
||||
if (!result.ok) return fail(400, { error: result.error });
|
||||
return {
|
||||
success: true,
|
||||
action: 'copy',
|
||||
copied: result.copied,
|
||||
targets: result.targets
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ vi.mock('$lib/db', () => ({
|
||||
proclamationCategory: { findMany: vi.fn() },
|
||||
proclamationGroup: { findMany: vi.fn() },
|
||||
proclamation: { findMany: vi.fn() },
|
||||
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() }
|
||||
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
schedule: { findMany: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -16,11 +17,13 @@ vi.mock('$lib/server/schedule', () => ({
|
||||
setScheduleType: vi.fn(),
|
||||
createBlock: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
deleteBlock: vi.fn()
|
||||
deleteBlock: vi.fn(),
|
||||
copySchedule: vi.fn()
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import {
|
||||
copySchedule,
|
||||
createBlock,
|
||||
deleteBlock,
|
||||
getScheduleForLicense,
|
||||
@@ -31,10 +34,12 @@ import { actions, load } from './+page.server';
|
||||
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
||||
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||
const scheduleFindMany = prisma.schedule.findMany as ReturnType<typeof vi.fn>;
|
||||
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
||||
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
||||
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
||||
const copyScheduleMock = copySchedule as ReturnType<typeof vi.fn>;
|
||||
|
||||
const locals = createLocals(mockUser() as never);
|
||||
|
||||
@@ -51,6 +56,7 @@ beforeEach(() => {
|
||||
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
|
||||
]);
|
||||
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
|
||||
scheduleFindMany.mockResolvedValue([]);
|
||||
getScheduleMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
@@ -197,4 +203,76 @@ describe('schedule actions', () => {
|
||||
}
|
||||
expect(deleteBlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copy rejects unauthenticated requests', async () => {
|
||||
await expect(
|
||||
actions.copy!({
|
||||
request: createFormRequest({ sourceLicenseKeyId: 'lk1' }),
|
||||
locals: createLocals()
|
||||
} as never)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('copy rejects a source not owned by the account', async () => {
|
||||
licenseFindMany.mockResolvedValue([]); // none owned
|
||||
|
||||
const result = await actions.copy!({
|
||||
request: createFormRequest({ sourceLicenseKeyId: 'lk-other' }),
|
||||
locals
|
||||
} as never);
|
||||
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
if (isActionFailure(result)) expect(result.status).toBe(404);
|
||||
expect(copyScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copy passes the owned systems and targets and returns a summary', async () => {
|
||||
licenseFindMany.mockResolvedValue([
|
||||
{ id: 'lk1', systemName: 'Living Room', key: 'AAAA-BBBB-CCCC-DDDD' },
|
||||
{ id: 'lk2', systemName: 'Kitchen', key: 'EEEE-FFFF-GGGG-HHHH' }
|
||||
]);
|
||||
copyScheduleMock.mockResolvedValue({
|
||||
ok: true,
|
||||
copied: 1,
|
||||
targets: [{ id: 'lk2', label: 'Kitchen' }]
|
||||
});
|
||||
|
||||
const form = new FormData();
|
||||
form.append('sourceLicenseKeyId', 'lk1');
|
||||
form.append('targetIds', 'lk2');
|
||||
const request = new Request('http://localhost', { method: 'POST', body: form });
|
||||
|
||||
const result = await actions.copy!({ request, locals } as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
action: 'copy',
|
||||
copied: 1,
|
||||
targets: [{ id: 'lk2', label: 'Kitchen' }]
|
||||
});
|
||||
expect(copyScheduleMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'lk1' }),
|
||||
expect.objectContaining({ id: 'lk2' })
|
||||
]),
|
||||
'lk1',
|
||||
['lk2']
|
||||
);
|
||||
});
|
||||
|
||||
it('copy surfaces a service error', async () => {
|
||||
copyScheduleMock.mockResolvedValue({ ok: false, error: 'Target system not found.' });
|
||||
|
||||
const form = new FormData();
|
||||
form.append('sourceLicenseKeyId', 'lk1');
|
||||
form.append('targetIds', 'lk-bad');
|
||||
const request = new Request('http://localhost', { method: 'POST', body: form });
|
||||
|
||||
const result = await actions.copy!({ request, locals } as never);
|
||||
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
if (isActionFailure(result)) {
|
||||
expect(result.data).toEqual({ error: 'Target system not found.' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user