import { randomUUID, createHash } from 'node:crypto'; import { readFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; import { NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { UploadSessionStatus } from '@prisma/client'; import { AppConfigService } from '../config/config.service'; import { DeviceAuthService } from '../auth/device-auth.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; import { OwnerContext } from '../users/owner-context.service'; import { UploadsService } from './uploads.service'; type MockState = ReturnType['state']; function createPrismaMock() { const users = new Map(); const devices = new Map(); const tracks = new Map(); const audioAssets = new Map(); const artworkAssets = new Map(); const uploadSessions = new Map(); const libraryEvents = new Map(); let nextLibraryEventId = 1n; const defaultUser = { id: randomUUID(), slug: 'default-owner', displayName: 'Default Owner', isDefault: true, createdAt: new Date(), updatedAt: new Date(), }; users.set(defaultUser.id, defaultUser); const prismaMock: any = { $queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]), $transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)), user: { upsert: jest.fn().mockResolvedValue(defaultUser), }, device: { findUnique: jest.fn().mockImplementation(async ({ where }) => { return devices.get(where.id) ?? null; }), create: jest.fn().mockImplementation(async ({ data }) => { const record = { id: data.id ?? randomUUID(), createdAt: new Date(), updatedAt: new Date(), ...data, }; devices.set(record.id, record); return record; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = devices.get(where.id); const updated = { ...current, ...data, updatedAt: new Date(), }; devices.set(where.id, updated); return updated; }), }, track: { findUnique: jest.fn().mockImplementation(async ({ where }) => { return tracks.get(where.id) ?? null; }), findMany: jest.fn().mockImplementation(async ({ where }) => { return [...tracks.values()] .filter((track) => { const userMatches = where?.userId ? track.userId === where.userId : true; const statusMatches = where?.status ? track.status === where.status : true; return userMatches && statusMatches; }) .sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime()); }), create: jest.fn().mockImplementation(async ({ data }) => { const now = new Date(); const record = { id: randomUUID(), primaryAudioAssetId: null, artworkAssetId: null, albumArtist: null, genre: null, discNumber: null, trackNumber: null, year: null, deletedAt: null, createdAt: now, updatedAt: now, ...data, }; tracks.set(record.id, record); return record; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = tracks.get(where.id); const updated = { ...current, ...data, updatedAt: new Date(), }; tracks.set(where.id, updated); return updated; }), }, audioAsset: { findUnique: jest.fn().mockImplementation(async ({ where }) => { if (where.id) { return audioAssets.get(where.id) ?? null; } const composite = where.userId_sha256; if (!composite) { return null; } return ( [...audioAssets.values()].find( (asset) => asset.userId === composite.userId && asset.sha256 === composite.sha256, ) ?? null ); }), create: jest.fn().mockImplementation(async ({ data }) => { const record = { id: randomUUID(), bitRateKbps: null, sampleRateHz: null, channels: null, createdAt: new Date(), ...data, }; audioAssets.set(record.id, record); return record; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = audioAssets.get(where.id); const updated = { ...current, ...data, }; audioAssets.set(where.id, updated); return updated; }), }, artworkAsset: { findFirst: jest.fn().mockImplementation(async ({ where }) => { return ( [...artworkAssets.values()].find( (asset) => asset.userId === where.userId && asset.sha256 === where.sha256, ) ?? null ); }), create: jest.fn().mockImplementation(async ({ data }) => { const record = { id: randomUUID(), createdAt: new Date(), ...data, }; artworkAssets.set(record.id, record); return record; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = artworkAssets.get(where.id); const updated = { ...current, ...data, }; artworkAssets.set(where.id, updated); return updated; }), }, uploadSession: { create: jest.fn().mockImplementation(async ({ data }) => { const now = new Date(); const record = { createdAt: now, updatedAt: now, completedAt: null, finalizedAt: null, trackId: null, audioAssetId: null, ...data, }; uploadSessions.set(record.id, record); return record; }), findUnique: jest.fn().mockImplementation(async ({ where }) => { return uploadSessions.get(where.id) ?? null; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = uploadSessions.get(where.id); const updated = { ...current, ...data, updatedAt: new Date(), }; uploadSessions.set(where.id, updated); return updated; }), }, libraryEvent: { create: jest.fn().mockImplementation(async ({ data }) => { const record = { id: nextLibraryEventId, payloadVersion: 1, createdAt: new Date(), ...data, }; libraryEvents.set(record.id, record); nextLibraryEventId += 1n; return record; }), findFirst: jest.fn().mockImplementation(async ({ where }) => { const filteredEvents = [...libraryEvents.values()].filter((event) => where?.userId ? event.userId === where.userId : true, ); return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null; }), }, state: { defaultUser, users, devices, tracks, audioAssets, artworkAssets, uploadSessions, libraryEvents, }, }; return { prismaMock, state: prismaMock.state, }; } function createAppConfig(storageRoot: string): AppConfigService { return { maxUploadSizeBytes: 10 * 1024 * 1024, storageRoot, } as AppConfigService; } function createUploadRequest(data: Buffer): any { const request = Readable.from([data]) as any; request.headers = { 'content-type': 'audio/mpeg', 'content-length': String(data.length), }; return request; } function sampleMp3Bytes(seed: string): Buffer { return Buffer.concat([ Buffer.from('ID3', 'ascii'), Buffer.from([0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21]), Buffer.from(seed, 'utf8'), ]); } function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } function sampleArtworkBytes(): Buffer { return Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', 'base64', ); } describe('UploadsService', () => { let prismaMock: any; let state: MockState; let storageRoot: string; let storageService: LocalFilesystemStorageService; let service: UploadsService; let ownerContext: OwnerContext; let deviceAuthService: { resolveCurrentDevice: jest.Mock; }; beforeEach(async () => { const mock = createPrismaMock(); prismaMock = mock.prismaMock; state = mock.state; storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-')); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); ownerContext = { resolve: jest.fn().mockResolvedValue({ userId: state.defaultUser.id, }), } as OwnerContext; deviceAuthService = { resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => { if (!deviceId) { throw new NotFoundException('Device not found'); } const device = state.devices.get(deviceId); if (!device || device.userId !== state.defaultUser.id) { throw new NotFoundException('Device not found'); } return { deviceId: device.id, userId: device.userId, }; }), }; service = new UploadsService( prismaMock, createAppConfig(storageRoot), storageService, ownerContext, deviceAuthService as unknown as DeviceAuthService, ); }); afterEach(async () => { await rm(storageRoot, { recursive: true, force: true }); }); function seedDevice() { const deviceId = randomUUID(); const device = { id: deviceId, userId: state.defaultUser.id, platform: 'MACOS', deviceName: 'Velody Mac', appVersion: '0.1.0', installTokenHash: sha256Hex(Buffer.from(deviceId, 'utf8')), lastSeenAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }; state.devices.set(deviceId, device); return device; } it('returns upload_required for a new sha during prepare', async () => { const device = seedDevice(); const bytes = sampleMp3Bytes('prepare-new-sha'); const response = await service.prepare({ deviceId: device.id, sha256: sha256Hex(bytes), originalFilename: 'track.mp3', sizeBytes: bytes.length, }); expect(response.status).toBe('upload_required'); expect(response.uploadId).toBeDefined(); expect(response.nextOffset).toBe(0); expect(state.uploadSessions.size).toBe(1); }); it('does not reuse foreign-owner audio assets during prepare', async () => { const device = seedDevice(); const bytes = sampleMp3Bytes('foreign-owner-asset'); const sha256 = sha256Hex(bytes); state.audioAssets.set(randomUUID(), { id: randomUUID(), userId: randomUUID(), trackId: randomUUID(), sha256, storageKey: 'users/foreign/audio/foreign-owner-asset.mp3', originalFilename: 'foreign-owner-asset.mp3', mimeType: 'audio/mpeg', fileExtension: 'mp3', fileSizeBytes: BigInt(bytes.length), durationMs: 180000, sourceDeviceId: randomUUID(), createdAt: new Date(), }); const response = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'foreign-owner-asset.mp3', sizeBytes: bytes.length, }); expect(response.status).toBe('upload_required'); }); it('rejects an upload whose bytes do not match the prepared sha', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('wrong-sha-upload'); const response = await service.prepare({ deviceId: device.id, sha256: sha256Hex(sampleMp3Bytes('different-bytes')), originalFilename: 'mismatch.mp3', sizeBytes: uploadedBytes.length, }); await expect( service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)), ).rejects.toBeInstanceOf(UnprocessableEntityException); const session = state.uploadSessions.get(response.uploadId!); expect(session.status).toBe(UploadSessionStatus.FAILED); }); it('stores valid MP3 bytes under the deterministic user storage path', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('valid-upload'); const sha256 = sha256Hex(uploadedBytes); const response = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'valid-upload.mp3', sizeBytes: uploadedBytes.length, }); const status = await service.uploadFile( response.uploadId!, createUploadRequest(uploadedBytes), ); expect(status.status).toBe(UploadSessionStatus.COMPLETED); const storedBytes = await readFile( join(storageRoot, 'users', state.defaultUser.id, 'audio', `${sha256}.mp3`), ); expect(storedBytes.equals(uploadedBytes)).toBe(true); }); it('finalize creates track, audio_asset, and library_event records', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('finalize-records'); const sha256 = sha256Hex(uploadedBytes); const response = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'finalize-records.mp3', sizeBytes: uploadedBytes.length, }); await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)); const finalizeResponse = await service.finalize(response.uploadId!, { title: 'Finalize Track', artist: 'Velody', album: 'Milestone 6', durationMs: 245000, }); expect(finalizeResponse.trackId).toBeDefined(); expect(finalizeResponse.assetId).toBeDefined(); expect(state.tracks.size).toBe(1); expect(state.audioAssets.size).toBe(1); expect(state.libraryEvents.size).toBe(1); const track = [...state.tracks.values()][0]; const audioAsset = [...state.audioAssets.values()][0]; const libraryEvent = [...state.libraryEvents.values()][0]; expect(track.userId).toBe(state.defaultUser.id); expect(audioAsset.userId).toBe(state.defaultUser.id); expect(libraryEvent.userId).toBe(state.defaultUser.id); const session = state.uploadSessions.get(response.uploadId!); expect(session.finalizedAt).toBeInstanceOf(Date); expect(session.trackId).toBe(finalizeResponse.trackId); expect(session.audioAssetId).toBe(finalizeResponse.assetId); }); it('finalize with embedded artwork creates and links an artwork asset', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('finalize-with-artwork'); const artworkBytes = sampleArtworkBytes(); const sha256 = sha256Hex(uploadedBytes); const artworkSHA256 = sha256Hex(artworkBytes); const response = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'finalize-with-artwork.mp3', sizeBytes: uploadedBytes.length, }); await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)); const finalizeResponse = await service.finalize(response.uploadId!, { title: 'Artwork Track', artist: 'Velody', album: 'Milestone 8.1', durationMs: 245000, artwork: { dataBase64: artworkBytes.toString('base64'), sha256: artworkSHA256, mimeType: 'image/png', width: 1, height: 1, }, }); expect(finalizeResponse.trackId).toBeDefined(); expect(state.artworkAssets.size).toBe(1); const track = [...state.tracks.values()][0]; const artworkAsset = [...state.artworkAssets.values()][0]; expect(track.artworkAssetId).toBe(artworkAsset.id); expect(artworkAsset.userId).toBe(state.defaultUser.id); expect(artworkAsset.sha256).toBe(artworkSHA256); expect(artworkAsset.mimeType).toBe('image/png'); const storedBytes = await readFile( join(storageRoot, 'users', state.defaultUser.id, 'artwork', `${artworkSHA256}.png`), ); expect(storedBytes.equals(artworkBytes)).toBe(true); }); it('returns exists from prepare after a successful upload and finalize', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('duplicate-handling'); const sha256 = sha256Hex(uploadedBytes); const firstPrepare = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'duplicate-handling.mp3', sizeBytes: uploadedBytes.length, }); await service.uploadFile( firstPrepare.uploadId!, createUploadRequest(uploadedBytes), ); const finalizeResponse = await service.finalize(firstPrepare.uploadId!, { title: 'Duplicate Track', artist: 'Velody', album: 'Milestone 6', durationMs: 123000, }); const secondPrepare = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'duplicate-handling.mp3', sizeBytes: uploadedBytes.length, }); expect(secondPrepare.status).toBe('exists'); expect(secondPrepare.uploadId).toBeDefined(); expect(secondPrepare.trackId).toBe(finalizeResponse.trackId); expect(secondPrepare.assetId).toBe(finalizeResponse.assetId); }); it('finalize on an exists prepare attaches artwork to an existing audio asset without re-uploading bytes', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('duplicate-artwork-update'); const artworkBytes = sampleArtworkBytes(); const sha256 = sha256Hex(uploadedBytes); const artworkSHA256 = sha256Hex(artworkBytes); const firstPrepare = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'duplicate-artwork-update.mp3', sizeBytes: uploadedBytes.length, }); await service.uploadFile( firstPrepare.uploadId!, createUploadRequest(uploadedBytes), ); const firstFinalize = await service.finalize(firstPrepare.uploadId!, { title: 'Duplicate Artwork Track', artist: 'Velody', album: 'Milestone 8.1', durationMs: 123000, }); const secondPrepare = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'duplicate-artwork-update.mp3', sizeBytes: uploadedBytes.length, }); expect(secondPrepare.status).toBe('exists'); expect(secondPrepare.uploadId).toBeDefined(); const secondFinalize = await service.finalize(secondPrepare.uploadId!, { title: 'Duplicate Artwork Track', artist: 'Velody', album: 'Milestone 8.1', durationMs: 123000, artwork: { dataBase64: artworkBytes.toString('base64'), sha256: artworkSHA256, mimeType: 'image/png', width: 1, height: 1, }, }); expect(secondFinalize.trackId).toBe(firstFinalize.trackId); expect(secondFinalize.assetId).toBe(firstFinalize.assetId); expect(state.audioAssets.size).toBe(1); expect(state.artworkAssets.size).toBe(1); const track = [...state.tracks.values()][0]; const artworkAsset = [...state.artworkAssets.values()][0]; expect(track.artworkAssetId).toBe(artworkAsset.id); expect(artworkAsset.sha256).toBe(artworkSHA256); const storedArtworkBytes = await readFile( join(storageRoot, 'users', state.defaultUser.id, 'artwork', `${artworkSHA256}.png`), ); expect(storedArtworkBytes.equals(artworkBytes)).toBe(true); }); it('rejects finalize when the upload session references a foreign-owner track', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('foreign-track-finalize'); const sha256 = sha256Hex(uploadedBytes); const response = await service.prepare({ deviceId: device.id, sha256, originalFilename: 'foreign-track-finalize.mp3', sizeBytes: uploadedBytes.length, }); await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)); const foreignTrackId = randomUUID(); state.tracks.set(foreignTrackId, { id: foreignTrackId, userId: randomUUID(), primaryAudioAssetId: null, artworkAssetId: null, title: 'Foreign Track', artist: 'Elsewhere', album: null, albumArtist: null, genre: null, discNumber: null, trackNumber: null, year: null, durationMs: 180000, status: 'ACTIVE', deletedAt: null, createdAt: new Date(), updatedAt: new Date(), }); state.uploadSessions.set(response.uploadId!, { ...state.uploadSessions.get(response.uploadId!), trackId: foreignTrackId, }); await expect( service.finalize(response.uploadId!, { title: 'Finalize Track', artist: 'Velody', album: 'Milestone 9', durationMs: 245000, }), ).rejects.toBeInstanceOf(NotFoundException); }); });