Portal: System Name PUT endpoint and license-validation status
Add PUT /api/v2/licenses/[licenseKey]/system-name (200/400 invalid_name/ 404 unknown_license/409 name_taken, idempotent) and extend POST /api/v2/license with an optional systemName and additive systemNameStatus (ok/unchanged/conflict/invalid). Validation always succeeds; an omitted field never changes or clears a name and empty/whitespace is invalid. Adds server and helper tests covering trimming, bounds, case-insensitive uniqueness, cross-account reuse, 404, and idempotency.
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
licenseKey: { findFirst: vi.fn(), update: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { applySystemName, normalizeSystemName, SYSTEM_NAME_MAX_LENGTH } from './system-name';
|
||||||
|
|
||||||
|
const keyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const keyUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSystemName', () => {
|
||||||
|
it('trims and returns a case-folded copy', () => {
|
||||||
|
expect(normalizeSystemName(' Living Room ')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
name: 'Living Room',
|
||||||
|
lower: 'living room'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the length bounds', () => {
|
||||||
|
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH)).ok).toBe(true);
|
||||||
|
expect(normalizeSystemName('a')).toEqual({ ok: true, name: 'a', lower: 'a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, whitespace, and out-of-range values', () => {
|
||||||
|
expect(normalizeSystemName('')).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(' ')).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH + 1))).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-strings', () => {
|
||||||
|
expect(normalizeSystemName(undefined)).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(null)).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(42)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applySystemName', () => {
|
||||||
|
it('reports unchanged without writing when identical', async () => {
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: 'Living Room' },
|
||||||
|
'Living Room',
|
||||||
|
'living room'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('unchanged');
|
||||||
|
expect(keyFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(keyUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports conflict when another license on the account holds the name', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue({ id: 'other' });
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('conflict');
|
||||||
|
expect(keyUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the name and its case-folded copy when free', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue(null);
|
||||||
|
keyUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('ok');
|
||||||
|
expect(keyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'k1' },
|
||||||
|
data: { systemName: 'Den', systemNameLower: 'den' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the uniqueness check for a license with no account', async () => {
|
||||||
|
keyUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: null, systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('ok');
|
||||||
|
expect(keyFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(keyUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a unique-constraint violation to conflict', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue(null);
|
||||||
|
keyUpdate.mockRejectedValue({ code: 'P2002' });
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('conflict');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// System Name helpers. A System Name is a human-readable label for a License
|
||||||
|
// Key, set only by the Kiosk and displayed read-only by the Portal. It is never
|
||||||
|
// a reference or foreign key. Uniqueness is per account (the owning User) and
|
||||||
|
// case-insensitive when non-null, enforced by the unique index on
|
||||||
|
// (userId, systemNameLower).
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
|
||||||
|
export const SYSTEM_NAME_MAX_LENGTH = 40;
|
||||||
|
|
||||||
|
export type SystemNameStatus = 'ok' | 'unchanged' | 'conflict' | 'invalid';
|
||||||
|
|
||||||
|
export type NormalizedName = { ok: true; name: string; lower: string } | { ok: false };
|
||||||
|
|
||||||
|
/** Trim and validate a caller-supplied System Name. Stored as typed (trimmed);
|
||||||
|
* `lower` is the case-folded copy used for uniqueness. */
|
||||||
|
export function normalizeSystemName(raw: unknown): NormalizedName {
|
||||||
|
if (typeof raw !== 'string') return { ok: false };
|
||||||
|
const name = raw.trim();
|
||||||
|
if (name.length < 1 || name.length > SYSTEM_NAME_MAX_LENGTH) return { ok: false };
|
||||||
|
return { ok: true, name, lower: name.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemNameLicense = {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
systemName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'P2002'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a normalized name to a license. Returns the resulting status:
|
||||||
|
* - `unchanged` when the stored name is already identical
|
||||||
|
* - `conflict` when another license on the same account holds the name
|
||||||
|
* - `ok` when the name was written
|
||||||
|
* A license with no owning account (legacy, userId null) is written without a
|
||||||
|
* uniqueness check; per-account uniqueness cannot apply without an account.
|
||||||
|
*/
|
||||||
|
export async function applySystemName(
|
||||||
|
license: SystemNameLicense,
|
||||||
|
name: string,
|
||||||
|
lower: string
|
||||||
|
): Promise<SystemNameStatus> {
|
||||||
|
if (license.systemName === name) return 'unchanged';
|
||||||
|
|
||||||
|
if (license.userId) {
|
||||||
|
const conflict = await prisma.licenseKey.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: license.userId,
|
||||||
|
systemNameLower: lower,
|
||||||
|
id: { not: license.id }
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (conflict) return 'conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.licenseKey.update({
|
||||||
|
where: { id: license.id },
|
||||||
|
data: { systemName: name, systemNameLower: lower }
|
||||||
|
});
|
||||||
|
return 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) return 'conflict';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,14 +139,16 @@ describe('serializeGroup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('serializeLicense', () => {
|
describe('serializeLicense', () => {
|
||||||
it('returns only key and email_address (levels removed)', () => {
|
it('returns key, email_address, and the advisory systemNameStatus', () => {
|
||||||
const result = serializeLicense({
|
const result = serializeLicense({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
emailAddress: 'user@example.com'
|
emailAddress: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
email_address: 'user@example.com'
|
email_address: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(Object.keys(result)).not.toContain('subscription_level');
|
expect(Object.keys(result)).not.toContain('subscription_level');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,13 +119,20 @@ export function serializeGroup(g: { id: number; name: string; sortOrder: number
|
|||||||
export type V2License = {
|
export type V2License = {
|
||||||
key: string;
|
key: string;
|
||||||
email_address: string;
|
email_address: string;
|
||||||
|
systemNameStatus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build the POST /license response. */
|
/** Build the POST /license response. `systemNameStatus` is advisory: the Kiosk
|
||||||
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
|
* treats "ok"/"unchanged" as synced and reverts on "conflict"/"invalid". */
|
||||||
|
export function serializeLicense(opts: {
|
||||||
|
key: string;
|
||||||
|
emailAddress: string;
|
||||||
|
systemNameStatus: string;
|
||||||
|
}): V2License {
|
||||||
return {
|
return {
|
||||||
key: opts.key,
|
key: opts.key,
|
||||||
email_address: opts.emailAddress
|
email_address: opts.emailAddress,
|
||||||
|
systemNameStatus: opts.systemNameStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { prisma } from '$lib/db';
|
|||||||
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
||||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
import { serializeLicense } from '$lib/server/v2-serializers';
|
import { serializeLicense } from '$lib/server/v2-serializers';
|
||||||
|
import {
|
||||||
|
applySystemName,
|
||||||
|
normalizeSystemName,
|
||||||
|
type SystemNameStatus
|
||||||
|
} from '$lib/server/system-name';
|
||||||
|
|
||||||
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
||||||
|
|
||||||
@@ -50,6 +55,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
throw error(400, 'key does not belong to specified user');
|
throw error(400, 'key does not belong to specified user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional System Name (Kiosk is the only writer). Validation always
|
||||||
|
// succeeds; a non-ok name status is advisory. An omitted field never
|
||||||
|
// changes or clears the stored name; empty/whitespace is invalid, never a
|
||||||
|
// clear.
|
||||||
|
let systemNameStatus: SystemNameStatus = 'unchanged';
|
||||||
|
if ('systemName' in body) {
|
||||||
|
const parsed = normalizeSystemName(body.systemName);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
systemNameStatus = 'invalid';
|
||||||
|
} else {
|
||||||
|
systemNameStatus = await applySystemName(storedKey, parsed.name, parsed.lower);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record that this active key was validated. A failure to record the
|
// Record that this active key was validated. A failure to record the
|
||||||
// timestamp should not fail an otherwise valid validation.
|
// timestamp should not fail an otherwise valid validation.
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +83,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return jsonOk(
|
return jsonOk(
|
||||||
serializeLicense({
|
serializeLicense({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
emailAddress: email
|
emailAddress: email,
|
||||||
|
systemNameStatus
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const storedKey = {
|
|||||||
id: 'key-1',
|
id: 'key-1',
|
||||||
key: 'ABCD-EFGH-IJKL-MNOP',
|
key: 'ABCD-EFGH-IJKL-MNOP',
|
||||||
active: true,
|
active: true,
|
||||||
userId: 'user-1'
|
userId: 'user-1',
|
||||||
|
systemName: null
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('POST /api/v2/license', () => {
|
describe('POST /api/v2/license', () => {
|
||||||
@@ -64,7 +65,8 @@ describe('POST /api/v2/license', () => {
|
|||||||
const body = (await res.json()) as Record<string, unknown>;
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
email_address: 'user@example.com'
|
email_address: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(Object.keys(body)).not.toContain('subscription_level');
|
expect(Object.keys(body)).not.toContain('subscription_level');
|
||||||
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
@@ -147,3 +149,90 @@ describe('POST /api/v2/license', () => {
|
|||||||
expect(mockKeyFindFirst).not.toHaveBeenCalled();
|
expect(mockKeyFindFirst).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v2/license systemName handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
_clearRateLimits();
|
||||||
|
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||||
|
mockKeyUpdate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function post(...args: unknown[]) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
license_key: storedKey.key,
|
||||||
|
email_address: 'user@example.com'
|
||||||
|
};
|
||||||
|
if (args.length > 0) body.systemName = args[0];
|
||||||
|
const res = await POST({ request: licenseRequest(body) } as POSTEvent);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('leaves the name untouched and reports unchanged when omitted', async () => {
|
||||||
|
const res = await post();
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('unchanged');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: storedKey.id },
|
||||||
|
data: { lastValidatedAt: expect.any(Date) }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a trimmed name, case-folded for uniqueness (ok)', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const res = await post(' Living Room ');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('ok');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: storedKey.id },
|
||||||
|
data: { systemName: 'Living Room', systemNameLower: 'living room' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports conflict without writing when another key holds the name', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('conflict');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats differing casing as a conflict', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('living room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('conflict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same name in a different account', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid without writing for empty, whitespace, too long, or non-string', async () => {
|
||||||
|
for (const bad of ['', ' ', 'a'.repeat(41), 42]) {
|
||||||
|
const res = await post(bad);
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('invalid');
|
||||||
|
}
|
||||||
|
// Only the lastValidatedAt stamp is written; no name writes.
|
||||||
|
for (const call of mockKeyUpdate.mock.calls) {
|
||||||
|
expect(call[0].data).toEqual({ lastValidatedAt: expect.any(Date) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validation still succeeds (200) on conflict', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
||||||
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
|
import { applySystemName, normalizeSystemName } from '$lib/server/system-name';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiosk-facing. Sets the System Name for a License Key. The Kiosk is the only
|
||||||
|
* writer; the Portal never calls this. Idempotent: re-sending the current name
|
||||||
|
* returns 200 without a write. Responds 400 `invalid_name`, 404
|
||||||
|
* `unknown_license`, or 409 `name_taken`.
|
||||||
|
*/
|
||||||
|
export const PUT: RequestHandler = async ({ request, params }) => {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const rl = rateLimit({ key: `system-name:${ip}`, limit: 10, windowMs: 60_000 });
|
||||||
|
if (!rl.ok) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenseKey = (params.licenseKey ?? '').trim();
|
||||||
|
const body = (await readJsonBody(request)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const parsed = normalizeSystemName(body.systemName);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw error(400, 'invalid_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
|
||||||
|
if (!license) {
|
||||||
|
throw error(404, 'unknown_license');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await applySystemName(license, parsed.name, parsed.lower);
|
||||||
|
if (status === 'conflict') {
|
||||||
|
throw error(409, 'name_taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonOk({ systemName: parsed.name, systemNameStatus: 'ok' });
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { isHttpError } from '@sveltejs/kit';
|
||||||
|
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
licenseKey: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { PUT } from './+server';
|
||||||
|
|
||||||
|
type PUTEvent = Parameters<typeof PUT>[0];
|
||||||
|
|
||||||
|
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||||
|
const mockFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const mockUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const KEY = 'ABCD-EFGH-IJKL-MNOP';
|
||||||
|
|
||||||
|
const storedKey = {
|
||||||
|
id: 'key-1',
|
||||||
|
key: KEY,
|
||||||
|
active: true,
|
||||||
|
userId: 'user-1',
|
||||||
|
systemName: null as string | null
|
||||||
|
};
|
||||||
|
|
||||||
|
function putRequest(body: unknown): Request {
|
||||||
|
return new Request(`http://localhost/api/v2/licenses/${KEY}/system-name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(body: unknown): PUTEvent {
|
||||||
|
return { request: putRequest(body), params: { licenseKey: KEY } } as PUTEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectHttpError(promise: unknown, status: number, message: string) {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (e) {
|
||||||
|
expect(isHttpError(e)).toBe(true);
|
||||||
|
const err = e as { status: number; body: { message: string } };
|
||||||
|
expect(err.status).toBe(status);
|
||||||
|
expect(err.body.message).toBe(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('expected handler to throw an HttpError');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
_clearRateLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/v2/licenses/[licenseKey]/system-name', () => {
|
||||||
|
it('sets the name and returns it', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(storedKey);
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
mockUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await PUT(event({ systemName: ' Living Room ' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'key-1' },
|
||||||
|
data: { systemName: 'Living Room', systemNameLower: 'living room' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent when the name is unchanged', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ ...storedKey, systemName: 'Living Room' });
|
||||||
|
|
||||||
|
const res = await PUT(event({ systemName: 'Living Room' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid name', async () => {
|
||||||
|
await expectHttpError(PUT(event({ systemName: ' ' })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({ systemName: '' })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'a'.repeat(41) })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({})), 400, 'invalid_name');
|
||||||
|
expect(mockFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown license', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'Den' })), 404, 'unknown_license');
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when another license on the account holds the name', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(storedKey);
|
||||||
|
mockFindFirst.mockResolvedValue({ id: 'other' });
|
||||||
|
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'Den' })), 409, 'name_taken');
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user