711 lines
22 KiB
TypeScript
711 lines
22 KiB
TypeScript
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<typeof createPrismaMock>['state'];
|
|
|
|
function createPrismaMock() {
|
|
const users = new Map<string, any>();
|
|
const devices = new Map<string, any>();
|
|
const tracks = new Map<string, any>();
|
|
const audioAssets = new Map<string, any>();
|
|
const artworkAssets = new Map<string, any>();
|
|
const uploadSessions = new Map<string, any>();
|
|
const libraryEvents = new Map<bigint, any>();
|
|
let nextLibraryEventId = 1n;
|
|
|
|
const defaultUser = {
|
|
id: randomUUID(),
|
|
slug: 'default-owner',
|
|
displayName: 'Default Owner',
|
|
isDefault: true,
|
|
libraryCursor: 0n,
|
|
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),
|
|
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
|
|
const current = users.get(where.id);
|
|
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
|
|
const updated = {
|
|
...current,
|
|
libraryCursor: BigInt(current.libraryCursor ?? 0) + incrementBy,
|
|
updatedAt: new Date(),
|
|
};
|
|
users.set(where.id, updated);
|
|
|
|
if (select?.libraryCursor) {
|
|
return {
|
|
libraryCursor: updated.libraryCursor,
|
|
};
|
|
}
|
|
|
|
return updated;
|
|
}),
|
|
},
|
|
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, orderBy }) => {
|
|
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
|
where?.userId ? event.userId === where.userId : true,
|
|
);
|
|
const direction = orderBy?.cursor ?? 'desc';
|
|
return filteredEvents
|
|
.sort((lhs, rhs) =>
|
|
direction === 'asc'
|
|
? Number(lhs.cursor - rhs.cursor)
|
|
: Number(rhs.cursor - lhs.cursor),
|
|
)[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 currentAuthenticatedDeviceId: string | null;
|
|
let deviceAuthService: {
|
|
getAuthenticatedDeviceOrThrow: 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));
|
|
currentAuthenticatedDeviceId = null;
|
|
ownerContext = {
|
|
resolve: jest.fn().mockResolvedValue({
|
|
userId: state.defaultUser.id,
|
|
}),
|
|
} as OwnerContext;
|
|
deviceAuthService = {
|
|
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
|
if (!currentAuthenticatedDeviceId) {
|
|
throw new NotFoundException('Device not found');
|
|
}
|
|
|
|
const device = state.devices.get(currentAuthenticatedDeviceId);
|
|
|
|
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);
|
|
currentAuthenticatedDeviceId = deviceId;
|
|
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(2);
|
|
|
|
const track = [...state.tracks.values()][0];
|
|
const audioAsset = [...state.audioAssets.values()][0];
|
|
const libraryEvents = [...state.libraryEvents.values()].sort((lhs, rhs) =>
|
|
Number(lhs.cursor - rhs.cursor),
|
|
);
|
|
|
|
expect(track.userId).toBe(state.defaultUser.id);
|
|
expect(audioAsset.userId).toBe(state.defaultUser.id);
|
|
expect(libraryEvents.map((event) => event.entityType)).toEqual([
|
|
'TRACK',
|
|
'AUDIO_ASSET',
|
|
]);
|
|
expect(libraryEvents.map((event) => event.action)).toEqual([
|
|
'CREATED',
|
|
'CREATED',
|
|
]);
|
|
expect(libraryEvents[0]?.payload.track.trackId).toBe(track.id);
|
|
expect(libraryEvents[1]?.payload.track.assetId).toBe(audioAsset.id);
|
|
expect(state.users.get(state.defaultUser.id)?.libraryCursor).toBe(2n);
|
|
|
|
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);
|
|
});
|
|
});
|