web-player: share form limits between client and server and extract form parsers

This commit is contained in:
2026-09-24 05:19:41 +02:00
parent 2915737f03
commit 1df7f6ccf6
18 changed files with 239 additions and 119 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
import { ALBUM_EARLIEST_YEAR } from '$lib/validation';
import { parseAlbumForm } from './albumForm';
function form(fields: Record<string, string>): FormData {
@@ -1,5 +1,5 @@
import { formString } from '$lib/server/api';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
interface AlbumFormBody {
title: string;
@@ -9,13 +9,15 @@ interface AlbumFormBody {
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > 200) {
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
if (title === '' || title.length > LIMITS.albumTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
}
const description = formString(form, 'description').trim();
if (description.length > 256) {
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
if (description.length > LIMITS.albumDescription) {
return {
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
};
}
const rawYear = formString(form, 'releaseYear').trim();
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parsePlaylistForm } from './playlistForm';
function form(fields: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) data.set(key, value);
return data;
}
describe('parsePlaylistForm', () => {
it('parses a valid form', () => {
expect(
parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' }))
).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } });
});
it('defaults to private', () => {
const parsed = parsePlaylistForm(form({ name: 'Mix' }));
expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } });
});
it('rejects empty or too long names keeping the typed values', () => {
expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({
failMessage: expect.any(String),
description: 'd'
});
expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty(
'failMessage'
);
});
it('rejects too long descriptions', () => {
const description = 'x'.repeat(LIMITS.playlistDescription + 1);
expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage');
});
});
@@ -0,0 +1,42 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface PlaylistFormBody {
name: string;
description: string;
visibility: 'Public' | 'Private';
cover: File | null;
}
export type PlaylistFormFailure = { failMessage: string; name: string; description: string };
export function parsePlaylistForm(
form: FormData
): { body: PlaylistFormBody } | PlaylistFormFailure {
const name = formString(form, 'name').trim();
const description = formString(form, 'description').trim();
if (name === '' || name.length > LIMITS.playlistName) {
return {
failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`,
name,
description
};
}
if (description.length > LIMITS.playlistDescription) {
return {
failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`,
name,
description
};
}
return {
body: {
name,
description,
visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private',
cover: formFile(form, 'cover')
}
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parseTrackUploadForm } from './trackUploadForm';
function form(fields: Record<string, string | string[] | File>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
else data.set(key, value);
}
return data;
}
const audio = new File(['a'], 'a.mp3');
const cover = new File(['c'], 'c.png');
describe('parseTrackUploadForm', () => {
it('parses a valid form and deduplicates tags', () => {
const parsed = parseTrackUploadForm(
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
);
expect(parsed).toMatchObject({
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
});
});
it('validates each field', () => {
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
expect(
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
});
});
@@ -0,0 +1,30 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface TrackUploadBody {
title: string;
audio: File;
cover: File;
tags: string[];
isExplicit: boolean;
}
export function parseTrackUploadForm(
form: FormData
): { body: TrackUploadBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.trackTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
}
const audio = formFile(form, 'audio');
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
const cover = formFile(form, 'cover');
if (!cover) return { failMessage: 'Selecciona una portada.' };
const tags = [...new Set(form.getAll('tags').map(String))];
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
}
+17
View File
@@ -78,6 +78,8 @@ export async function putPresigned(
});
}
import type { ApiClient } from '$lib/server/api';
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage(
@@ -109,3 +111,18 @@ export async function uploadPresignedImage(
return { intentId: reserved.intentId };
}
export async function uploadOptionalCover(
api: ApiClient,
endpoint: '/albums/upload-picture' | '/playlists/upload-picture',
file: File | null
): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> {
if (!file) return { intentId: null };
return uploadPresignedImage(
(args) =>
endpoint === '/albums/upload-picture'
? api.POST('/albums/upload-picture', { body: args })
: api.POST('/playlists/upload-picture', { body: args }),
file
);
}