Portal: copy action and hasSchedule flag in the schedule loader
Adds a session-authenticated copy action that scopes the source and targets to the caller's License Keys and returns a summary of the systems updated. The loader now reports hasSchedule per system so the Copy UI can warn before overwriting. Adds action tests (auth, cross-account, summary, error).
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import {
|
import {
|
||||||
|
copySchedule,
|
||||||
createBlock,
|
createBlock,
|
||||||
deleteBlock,
|
deleteBlock,
|
||||||
getScheduleForLicense,
|
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 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 type: 'daily' | 'weekly' | null = null;
|
||||||
let blocks: {
|
let blocks: {
|
||||||
@@ -102,7 +118,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
licenses,
|
licenses: licenseViews,
|
||||||
selectedLicenseId: selected?.id ?? null,
|
selectedLicenseId: selected?.id ?? null,
|
||||||
type,
|
type,
|
||||||
blocks,
|
blocks,
|
||||||
@@ -167,5 +183,34 @@ export const actions: Actions = {
|
|||||||
const result = await deleteBlock(licenseKeyId, id);
|
const result = await deleteBlock(licenseKeyId, id);
|
||||||
if (!result.ok) return fail(400, { error: result.error });
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
return { success: true };
|
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() },
|
proclamationCategory: { findMany: vi.fn() },
|
||||||
proclamationGroup: { findMany: vi.fn() },
|
proclamationGroup: { findMany: vi.fn() },
|
||||||
proclamation: { 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(),
|
setScheduleType: vi.fn(),
|
||||||
createBlock: vi.fn(),
|
createBlock: vi.fn(),
|
||||||
updateBlock: vi.fn(),
|
updateBlock: vi.fn(),
|
||||||
deleteBlock: vi.fn()
|
deleteBlock: vi.fn(),
|
||||||
|
copySchedule: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import {
|
import {
|
||||||
|
copySchedule,
|
||||||
createBlock,
|
createBlock,
|
||||||
deleteBlock,
|
deleteBlock,
|
||||||
getScheduleForLicense,
|
getScheduleForLicense,
|
||||||
@@ -31,10 +34,12 @@ import { actions, load } from './+page.server';
|
|||||||
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||||
const licenseFindMany = prisma.licenseKey.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 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 getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||||
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
||||||
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
||||||
const deleteBlockMock = deleteBlock 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);
|
const locals = createLocals(mockUser() as never);
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ beforeEach(() => {
|
|||||||
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
|
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
|
||||||
]);
|
]);
|
||||||
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
|
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
|
||||||
|
scheduleFindMany.mockResolvedValue([]);
|
||||||
getScheduleMock.mockResolvedValue(null);
|
getScheduleMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,4 +203,76 @@ describe('schedule actions', () => {
|
|||||||
}
|
}
|
||||||
expect(deleteBlockMock).not.toHaveBeenCalled();
|
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