Add direct upload API for presigning, creating tracks and following processing

This commit is contained in:
2026-09-25 01:51:19 +02:00
parent 93d7a23c53
commit 493cc74cfb
9 changed files with 756 additions and 6 deletions
+42
View File
@@ -0,0 +1,42 @@
export interface PresignFile {
fileName: string;
contentType: string;
size: number;
}
export interface PresignRequest {
cover: PresignFile | null;
files: PresignFile[];
}
export interface PresignTarget {
uploadUrl: string;
contentType: string;
}
export interface PresignedAudio extends PresignTarget {
intentId: string;
pictureIntentId: string | null;
}
export interface PresignResponse {
picture: PresignTarget | null;
audios: PresignedAudio[];
}
export interface CompleteRequest {
title: string;
audioIntentId: string;
pictureIntentId: string | null;
tags: string[];
albumId: string | null;
}
export interface CompleteResponse {
trackId: string;
note: string | null;
}
export type TrackProcessing = 'Pending' | 'Processing' | 'Completed' | 'Failed';
export type StatusResponse = Record<string, TrackProcessing>;
+109 -1
View File
@@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
beginUpload,
chunkStartable,
failUpload,
finishCreate,
finishProcessing,
finishUpload,
genreConflictMessage,
hasActiveUploads,
reportProgress,
retryUpload,
summarizeQueue, summarizeQueue,
titleFromFileName, titleFromFileName,
toQueuedUpload, toQueuedUpload,
@@ -41,7 +51,14 @@ describe('uploadError', () => {
describe('summarizeQueue', () => { describe('summarizeQueue', () => {
it('counts ready and failed items', () => { it('counts ready and failed items', () => {
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload); const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 }); expect(summarizeQueue(items)).toEqual({
ready: 2,
failed: 1,
uploading: 0,
processing: 0,
done: 0,
errored: 0
});
}); });
}); });
@@ -52,3 +69,94 @@ describe('uploadLimitsLabel', () => {
); );
}); });
}); });
describe('upload state machine', () => {
const fresh = () => toQueuedUpload(file('a.wav'));
it('walks queued to done', () => {
let item = fresh();
expect(item.phase).toBe('queued');
item = beginUpload(item);
expect(item.phase).toBe('uploading');
item = reportProgress(item, 0.4);
expect(item.progress).toBe(0.4);
item = finishUpload(item);
expect(item).toMatchObject({ phase: 'creating', progress: 1 });
item = finishCreate(item, 't1');
expect(item).toMatchObject({ phase: 'processing', trackId: 't1' });
item = finishProcessing(item);
expect(item.phase).toBe('done');
});
it('ignores transitions from the wrong phase', () => {
const item = fresh();
expect(finishUpload(item)).toBe(item);
expect(finishCreate(item, 't1')).toBe(item);
expect(finishProcessing(item)).toBe(item);
expect(failUpload(item, 'x')).toBe(item);
expect(retryUpload(item)).toBe(item);
});
it('never starts an invalid file', () => {
const item = toQueuedUpload(file('a.psd'));
expect(beginUpload(item)).toBe(item);
});
it('keeps progress monotonic and capped at 1', () => {
let item = beginUpload(fresh());
item = reportProgress(item, 0.6);
expect(reportProgress(item, 0.3)).toBe(item);
expect(reportProgress(item, 4).progress).toBe(1);
});
it('fails and retries', () => {
let item = beginUpload(fresh());
item = failUpload(item, 'Cancelada');
expect(item).toMatchObject({ phase: 'error', failure: 'Cancelada' });
item = retryUpload(item);
expect(item).toMatchObject({ phase: 'queued', progress: 0, failure: null });
});
it('carries a note when the album step fails', () => {
const item = finishCreate(finishUpload(beginUpload(fresh())), 't1', 'Sin álbum');
expect(item.note).toBe('Sin álbum');
});
});
describe('chunkStartable', () => {
it('splits startable items into batches and skips the rest', () => {
const items = ['a.wav', 'b.wav', 'c.wav', 'd.psd'].map((name) => toQueuedUpload(file(name)));
items[1] = beginUpload(items[1]);
const chunks = chunkStartable(items, 1);
expect(chunks.map((chunk) => chunk.map((item) => item.file.name))).toEqual([
['a.wav'],
['c.wav']
]);
});
});
describe('hasActiveUploads', () => {
it('is true while uploading, creating or processing', () => {
const item = toQueuedUpload(file('a.wav'));
expect(hasActiveUploads([item])).toBe(false);
expect(hasActiveUploads([beginUpload(item)])).toBe(true);
});
});
describe('genreConflictMessage', () => {
const options = [
{ genre: 'Rock', incompatibleWith: ['Classical'] },
{ genre: 'Classical', incompatibleWith: ['Rock'] },
{ genre: 'Jazz', incompatibleWith: [] }
];
it('names the conflicting genres', () => {
expect(genreConflictMessage(['Rock', 'Classical'], options)).toBe(
'Los géneros Rock y Clásica no se pueden combinar.'
);
});
it('returns nothing when compatible', () => {
expect(genreConflictMessage(['Rock', 'Jazz'], options)).toBeUndefined();
});
});
+105 -5
View File
@@ -1,3 +1,6 @@
import { findConflict, genreInfo } from '$lib/data/genres';
import type { GenreOption } from '$lib/types';
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024; export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3']; export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
@@ -9,7 +12,10 @@ export function uploadLimitsLabel(): string {
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`; return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
} }
export type Visibility = 'private' | 'public'; export const UPLOAD_CONCURRENCY = 3;
export const UPLOAD_BATCH_SIZE = 20;
export type UploadPhase = 'queued' | 'uploading' | 'creating' | 'processing' | 'done' | 'error';
export interface QueuedUpload { export interface QueuedUpload {
id: string; id: string;
@@ -18,6 +24,11 @@ export interface QueuedUpload {
format: string; format: string;
error: string | null; error: string | null;
duration: number | null; duration: number | null;
phase: UploadPhase;
progress: number;
failure: string | null;
trackId: string | null;
note: string | null;
} }
export function fileExtension(name: string): string { export function fileExtension(name: string): string {
@@ -50,13 +61,102 @@ export function toQueuedUpload(file: File): QueuedUpload {
title: titleFromFileName(file.name), title: titleFromFileName(file.name),
format: fileExtension(file.name).toUpperCase() || '?', format: fileExtension(file.name).toUpperCase() || '?',
error: uploadError(file), error: uploadError(file),
duration: null duration: null,
phase: 'queued',
progress: 0,
failure: null,
trackId: null,
note: null
}; };
} }
export function summarizeQueue(items: QueuedUpload[]): { ready: number; failed: number } { export function isStartable(item: QueuedUpload): boolean {
const failed = items.filter((item) => item.error !== null).length; return item.error === null && item.phase === 'queued';
return { ready: items.length - failed, failed }; }
export function isBusy(item: QueuedUpload): boolean {
return item.phase === 'uploading' || item.phase === 'creating';
}
export function isActive(item: QueuedUpload): boolean {
return isBusy(item) || item.phase === 'processing';
}
export function beginUpload(item: QueuedUpload): QueuedUpload {
if (!isStartable(item)) return item;
return { ...item, phase: 'uploading', progress: 0, failure: null };
}
export function reportProgress(item: QueuedUpload, fraction: number): QueuedUpload {
if (item.phase !== 'uploading') return item;
const progress = Math.min(1, Math.max(item.progress, fraction));
return progress === item.progress ? item : { ...item, progress };
}
export function finishUpload(item: QueuedUpload): QueuedUpload {
if (item.phase !== 'uploading') return item;
return { ...item, phase: 'creating', progress: 1 };
}
export function finishCreate(
item: QueuedUpload,
trackId: string,
note: string | null = null
): QueuedUpload {
if (item.phase !== 'creating') return item;
return { ...item, phase: 'processing', trackId, note };
}
export function finishProcessing(item: QueuedUpload): QueuedUpload {
if (item.phase !== 'processing') return item;
return { ...item, phase: 'done' };
}
export function failUpload(item: QueuedUpload, reason: string): QueuedUpload {
if (!isActive(item)) return item;
return { ...item, phase: 'error', failure: reason };
}
export function retryUpload(item: QueuedUpload): QueuedUpload {
if (item.phase !== 'error') return item;
return { ...item, phase: 'queued', progress: 0, failure: null, trackId: null, note: null };
}
export function genreConflictMessage(tags: string[], options: GenreOption[]): string | undefined {
const conflict = findConflict(tags, options);
if (!conflict) return undefined;
return `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`;
}
export function chunkStartable(items: QueuedUpload[], size = UPLOAD_BATCH_SIZE): QueuedUpload[][] {
const startable = items.filter(isStartable);
const chunks: QueuedUpload[][] = [];
for (let i = 0; i < startable.length; i += size) chunks.push(startable.slice(i, i + size));
return chunks;
}
export function hasActiveUploads(items: QueuedUpload[]): boolean {
return items.some(isActive);
}
export function summarizeQueue(items: QueuedUpload[]): {
ready: number;
failed: number;
uploading: number;
processing: number;
done: number;
errored: number;
} {
const invalid = items.filter((item) => item.error !== null).length;
const valid = items.filter((item) => item.error === null);
return {
ready: valid.filter((item) => item.phase === 'queued').length,
failed: invalid,
uploading: valid.filter(isBusy).length,
processing: valid.filter((item) => item.phase === 'processing').length,
done: valid.filter((item) => item.phase === 'done').length,
errored: valid.filter((item) => item.phase === 'error').length
};
} }
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> { async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiClient } from '$lib/server/api';
import {
UploadError,
completeTrackUpload,
parseCompleteRequest,
parsePresignRequest,
parseStatusIds,
presignTrackUploads,
trackProcessingStatus
} from '$lib/server/trackUploads';
const file = { fileName: 'a.wav', contentType: '', size: 10 };
function api(handlers: {
get?: (path: string) => unknown;
post?: (path: string, init: unknown) => unknown;
}) {
return {
GET: vi.fn(async (path: string) => handlers.get?.(path) ?? {}),
POST: vi.fn(async (path: string, init: unknown) => handlers.post?.(path, init) ?? {})
};
}
const asApi = (fake: unknown) => fake as ApiClient;
describe('parsePresignRequest', () => {
it('accepts up to the batch size', () => {
expect(parsePresignRequest({ cover: null, files: [file] }).files).toHaveLength(1);
});
it('rejects empty, oversized and malformed batches', () => {
expect(() => parsePresignRequest({ files: [] })).toThrow(UploadError);
expect(() => parsePresignRequest({ files: Array(21).fill(file) })).toThrow(UploadError);
expect(() => parsePresignRequest({ files: [{ fileName: 'a' }] })).toThrow(UploadError);
expect(() => parsePresignRequest({ files: [{ ...file, size: 0 }] })).toThrow(UploadError);
expect(() => parsePresignRequest(null)).toThrow(UploadError);
});
it('rejects a malformed cover', () => {
expect(() => parsePresignRequest({ cover: 3, files: [file] })).toThrow(UploadError);
});
});
describe('presignTrackUploads', () => {
it('sends every audio and the shared cover, and maps the intents', async () => {
const fake = api({
post: () => ({
data: {
pictureUploadUrl: 'https://s3/pic',
pictureContentType: 'image/png',
audios: [
{
intentId: 'i1',
pictureIntentId: 'p1',
uploadUrl: 'https://s3/a',
contentType: 'audio/wav'
}
]
}
})
});
const result = await presignTrackUploads(asApi(fake), {
cover: { fileName: 'c.png', contentType: 'image/png', size: 5 },
files: [file]
});
expect(result.picture).toEqual({ uploadUrl: 'https://s3/pic', contentType: 'image/png' });
expect(result.audios[0]).toMatchObject({ intentId: 'i1', pictureIntentId: 'p1' });
const body = fake.POST.mock.calls[0][1] as { body: Record<string, unknown> };
expect(body.body).toMatchObject({
pictureFileType: 'png',
audios: [{ fileType: 'wav', contentType: 'audio/wav', expectedSizeBytes: 10 }]
});
});
it('has no picture target without a cover', async () => {
const fake = api({
post: () => ({
data: {
pictureUploadUrl: null,
pictureContentType: null,
audios: [{ intentId: 'i1', pictureIntentId: null, uploadUrl: 'u', contentType: 'a' }]
}
})
});
const result = await presignTrackUploads(asApi(fake), { cover: null, files: [file] });
expect(result.picture).toBeNull();
});
it('fails when the backend errors', async () => {
const fake = api({ post: () => ({ error: { detail: 'nope' } }) });
await expect(
presignTrackUploads(asApi(fake), { cover: null, files: [file] })
).rejects.toMatchObject({ status: 502, message: 'nope' });
});
});
describe('parseCompleteRequest', () => {
const valid = { title: ' Song ', audioIntentId: 'a', tags: ['Rock', 'Rock'] };
it('trims and dedupes', () => {
expect(parseCompleteRequest(valid)).toEqual({
title: 'Song',
audioIntentId: 'a',
pictureIntentId: null,
tags: ['Rock'],
albumId: null
});
});
it('requires title, intent and genres', () => {
expect(() => parseCompleteRequest({ ...valid, title: ' ' })).toThrow(UploadError);
expect(() => parseCompleteRequest({ ...valid, audioIntentId: '' })).toThrow(UploadError);
expect(() => parseCompleteRequest({ ...valid, tags: [] })).toThrow(UploadError);
});
});
describe('completeTrackUpload', () => {
const request = {
title: 'Song',
audioIntentId: 'a',
pictureIntentId: null,
tags: ['Rock'],
albumId: null as string | null
};
const genres = {
data: [
{ genre: 'Rock', incompatibleWith: ['Classical'] },
{ genre: 'Classical', incompatibleWith: ['Rock'] }
]
};
function build(albumResult: unknown = {}) {
return api({
get: () => genres,
post: (path) => (path === '/tracks' ? { data: { id: 't1' } } : albumResult)
});
}
it('creates an unpublished-by-default track', async () => {
const fake = build();
expect(await completeTrackUpload(asApi(fake), request)).toEqual({ trackId: 't1', note: null });
expect(fake.POST).toHaveBeenCalledTimes(1);
});
it('rejects unknown and conflicting genres before creating', async () => {
const fake = build();
await expect(
completeTrackUpload(asApi(fake), { ...request, tags: ['Nope'] })
).rejects.toMatchObject({ status: 400 });
await expect(
completeTrackUpload(asApi(fake), { ...request, tags: ['Rock', 'Classical'] })
).rejects.toMatchObject({ status: 400 });
expect(fake.POST).not.toHaveBeenCalled();
});
it('adds the track to the album', async () => {
const fake = build();
await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
expect(fake.POST).toHaveBeenLastCalledWith('/albums/{id}/tracks', {
params: { path: { id: 'al' } },
body: { trackIds: ['t1'] }
});
});
it('keeps the track and reports a note when the album step fails', async () => {
const fake = build({ error: { status: 500 } });
const result = await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
expect(result.trackId).toBe('t1');
expect(result.note).toContain('álbum');
});
});
describe('track processing status', () => {
it('parses ids', () => {
expect(parseStatusIds('a,b,a')).toEqual(['a', 'b']);
expect(() => parseStatusIds(null)).toThrow(UploadError);
});
it('maps the audio status of each track', async () => {
const fake = api({
get: (path) => (path === '/tracks/{id}' ? { data: { audioStatus: 'Completed' } } : {})
});
expect(await trackProcessingStatus(asApi(fake), ['a'])).toEqual({ a: 'Completed' });
});
});
+187
View File
@@ -0,0 +1,187 @@
import type { ApiClient } from '$lib/server/api';
import { apiErrorDetail } from '$lib/server/api';
import { AUDIO_TYPES, IMAGE_TYPES, extOf } from '$lib/server/upload';
import { genreConflictMessage, UPLOAD_BATCH_SIZE, UPLOAD_MAX_BYTES } from '$lib/data/uploadQueue';
import type {
CompleteRequest,
CompleteResponse,
PresignFile,
PresignRequest,
PresignResponse,
StatusResponse,
TrackProcessing
} from '$lib/data/uploadProtocol';
import { LIMITS } from '$lib/validation';
import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre'];
export class UploadError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
}
}
function contentType(file: PresignFile, table: Record<string, string>): string {
return file.contentType || table[extOf(file.fileName)] || 'application/octet-stream';
}
function isFile(value: unknown): value is PresignFile {
if (typeof value !== 'object' || value === null) return false;
const { fileName, contentType: type, size } = value as Record<string, unknown>;
return (
typeof fileName === 'string' &&
fileName !== '' &&
typeof type === 'string' &&
typeof size === 'number' &&
Number.isFinite(size)
);
}
export function parsePresignRequest(payload: unknown): PresignRequest {
const { cover, files } = (payload ?? {}) as { cover?: unknown; files?: unknown };
if (!Array.isArray(files) || files.length === 0 || files.length > UPLOAD_BATCH_SIZE) {
throw new UploadError(`Envía entre 1 y ${UPLOAD_BATCH_SIZE} archivos.`, 400);
}
if (!files.every(isFile)) throw new UploadError('Archivo de audio inválido.', 400);
if (files.some((file) => file.size <= 0 || file.size > UPLOAD_MAX_BYTES)) {
throw new UploadError('Un archivo está vacío o supera el tamaño máximo.', 400);
}
if (cover !== null && cover !== undefined && !isFile(cover)) {
throw new UploadError('Portada inválida.', 400);
}
return { cover: cover ?? null, files };
}
export async function presignTrackUploads(
api: ApiClient,
request: PresignRequest
): Promise<PresignResponse> {
const { cover, files } = request;
const result = await api.POST('/tracks/upload-urls', {
body: {
pictureFileType: cover ? extOf(cover.fileName) || 'jpg' : null,
pictureContentType: cover ? contentType(cover, IMAGE_TYPES) : null,
expectedPictureSizeBytes: cover?.size ?? null,
audios: files.map((file) => ({
fileType: extOf(file.fileName) || 'mp3',
contentType: contentType(file, AUDIO_TYPES),
expectedSizeBytes: file.size
}))
}
});
if (result.error || !result.data || result.data.audios.length !== files.length) {
throw new UploadError(
apiErrorDetail(result.error) ?? 'No se pudieron reservar las URLs de subida.',
502
);
}
const { pictureUploadUrl, pictureContentType, audios } = result.data;
return {
picture:
pictureUploadUrl && pictureContentType
? { uploadUrl: pictureUploadUrl, contentType: pictureContentType }
: null,
audios: audios.map((audio) => ({
intentId: audio.intentId,
pictureIntentId: audio.pictureIntentId,
uploadUrl: audio.uploadUrl,
contentType: audio.contentType
}))
};
}
function idOrNull(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null;
}
export function parseCompleteRequest(payload: unknown): CompleteRequest {
const body = (payload ?? {}) as Record<string, unknown>;
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (title === '' || title.length > LIMITS.trackTitle) {
throw new UploadError(`El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).`, 400);
}
const audioIntentId = idOrNull(body.audioIntentId);
if (!audioIntentId) throw new UploadError('Subida inválida.', 400);
const tags = Array.isArray(body.tags)
? [...new Set(body.tags.filter((tag): tag is string => typeof tag === 'string'))]
: [];
if (tags.length === 0) throw new UploadError('Elige al menos un género.', 400);
return {
title,
audioIntentId,
pictureIntentId: idOrNull(body.pictureIntentId),
tags,
albumId: idOrNull(body.albumId)
};
}
export async function completeTrackUpload(
api: ApiClient,
request: CompleteRequest
): Promise<CompleteResponse> {
const { data: available } = await api.GET('/genres/available');
const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre));
if (!request.tags.every((tag) => known.has(tag))) {
throw new UploadError('Alguno de los géneros no es válido.', 400);
}
const conflict = genreConflictMessage(request.tags, options);
if (conflict) throw new UploadError(conflict, 400);
const created = await api.POST('/tracks', {
body: {
title: request.title,
pictureIntentId: request.pictureIntentId,
audioIntentId: request.audioIntentId,
tags: request.tags as Genre[],
isExplicit: false
}
});
if (created.error || !created.data) {
throw new UploadError(
apiErrorDetail(created.error) ?? 'No se pudo crear la canción tras la subida.',
502
);
}
const trackId = created.data.id;
if (!request.albumId) return { trackId, note: null };
const added = await api.POST('/albums/{id}/tracks', {
params: { path: { id: request.albumId } },
body: { trackIds: [trackId] }
});
return {
trackId,
note: added.error ? 'Se creó, pero no se pudo añadir al álbum.' : null
};
}
export function parseStatusIds(raw: string | null): string[] {
const ids = [...new Set((raw ?? '').split(',').filter(Boolean))];
if (ids.length === 0 || ids.length > UPLOAD_BATCH_SIZE) {
throw new UploadError('Canciones inválidas.', 400);
}
return ids;
}
export async function trackProcessingStatus(
api: ApiClient,
ids: string[]
): Promise<StatusResponse> {
const entries = await Promise.all(
ids.map(async (id) => {
const result = await api.GET('/tracks/{id}', { params: { path: { id } } });
return [id, result.data?.audioStatus] as const;
})
);
const known = entries.filter(
(entry): entry is readonly [string, TrackProcessing] => entry[1] !== undefined
);
return Object.fromEntries(known);
}
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, parsePresignRequest, presignTrackUploads } from '$lib/server/trackUploads';
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const parsed = parsePresignRequest(await request.json().catch(() => null));
return json(await presignTrackUploads(apiFor({ fetch, locals }), parsed));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, completeTrackUpload, parseCompleteRequest } from '$lib/server/trackUploads';
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const parsed = parseCompleteRequest(await request.json().catch(() => null));
return json(await completeTrackUpload(apiFor({ fetch, locals }), parsed));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
import { POST } from './+server';
import { POST as COMPLETE } from './complete/+server';
import { GET as STATUS } from './status/+server';
type PostEvent = Parameters<typeof POST>[0];
type CompleteEvent = Parameters<typeof COMPLETE>[0];
type StatusEvent = Parameters<typeof STATUS>[0];
function event(body: unknown, token: string | null = 'token') {
return {
request: new Request('http://x/api/uploads', { method: 'POST', body: JSON.stringify(body) }),
locals: { accessToken: token, user: null },
fetch: vi.fn()
};
}
describe('POST /api/uploads', () => {
it('requires a session', async () => {
const res = await POST(event({}, null) as unknown as PostEvent);
expect(res.status).toBe(401);
});
it('rejects an invalid batch', async () => {
const res = await POST(event({ files: [] }) as unknown as PostEvent);
expect(res.status).toBe(400);
});
it('proxies the presign request to the API', async () => {
const ev = event({ cover: null, files: [{ fileName: 'a.wav', contentType: '', size: 4 }] });
ev.fetch.mockResolvedValue(
Response.json({
pictureUploadUrl: null,
pictureContentType: null,
audios: [{ intentId: 'i', pictureIntentId: null, uploadUrl: 'u', contentType: 'audio/wav' }]
})
);
const res = await POST(ev as unknown as PostEvent);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ picture: null, audios: [{ intentId: 'i' }] });
});
});
describe('POST /api/uploads/complete', () => {
it('requires a session', async () => {
const res = await COMPLETE(event({}, null) as unknown as CompleteEvent);
expect(res.status).toBe(401);
});
it('rejects a missing title', async () => {
const res = await COMPLETE(
event({ title: '', audioIntentId: 'a', tags: ['Rock'] }) as unknown as CompleteEvent
);
expect(res.status).toBe(400);
});
});
describe('GET /api/uploads/status', () => {
it('requires a session', async () => {
const res = await STATUS({
url: new URL('http://x/api/uploads/status?ids=a'),
locals: { accessToken: null, user: null },
fetch: vi.fn()
} as unknown as StatusEvent);
expect(res.status).toBe(401);
});
it('rejects missing ids', async () => {
const res = await STATUS({
url: new URL('http://x/api/uploads/status'),
locals: { accessToken: 't', user: null },
fetch: vi.fn()
} as unknown as StatusEvent);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, parseStatusIds, trackProcessingStatus } from '$lib/server/trackUploads';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const ids = parseStatusIds(url.searchParams.get('ids'));
return json(await trackProcessingStatus(apiFor({ fetch, locals }), ids));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};