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.
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
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');
|
|
});
|
|
});
|