Fix artwork upload pipeline
This commit is contained in:
@@ -5,12 +5,13 @@ import { dirname, join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
ForbiddenException,
|
||||
INestApplication,
|
||||
NotFoundException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||
@@ -218,6 +219,33 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return artworkAssets.get(where.id) ?? null;
|
||||
}),
|
||||
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 }) => {
|
||||
@@ -284,7 +312,7 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('Velody API wiring (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let app: NestExpressApplication;
|
||||
let assetsController: AssetsController;
|
||||
let artworkController: ArtworkController;
|
||||
let healthController: HealthController;
|
||||
@@ -314,7 +342,12 @@ describe('Velody API wiring (e2e)', () => {
|
||||
.useValue(prismaMock)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
app = moduleRef.createNestApplication({ bodyParser: false });
|
||||
app.useBodyParser('json', { limit: API_JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', {
|
||||
extended: true,
|
||||
limit: API_JSON_BODY_LIMIT,
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(
|
||||
@@ -515,10 +548,16 @@ describe('Velody API wiring (e2e)', () => {
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const storageKey = join('library', 'artwork', `${artworkId}.png`);
|
||||
const storageKey = join(
|
||||
'users',
|
||||
prismaState.defaultUser.id,
|
||||
'artwork',
|
||||
`${sha256Hex(bytes)}.png`,
|
||||
);
|
||||
|
||||
prismaState.artworkAssets.set(artworkId, {
|
||||
id: artworkId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
sha256: sha256Hex(bytes),
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
@@ -526,9 +565,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
height: 1,
|
||||
fileSizeBytes: BigInt(bytes.length),
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
track: {
|
||||
userId: prismaState.defaultUser.id,
|
||||
},
|
||||
tracks: [{ userId: prismaState.defaultUser.id }],
|
||||
});
|
||||
prismaState.tracks.set(trackId, {
|
||||
id: trackId,
|
||||
@@ -583,16 +620,20 @@ describe('Velody API wiring (e2e)', () => {
|
||||
|
||||
prismaState.artworkAssets.set(artworkId, {
|
||||
id: artworkId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
sha256: 'sha-missing-artwork',
|
||||
storageKey: join('library', 'artwork', `${artworkId}.png`),
|
||||
storageKey: join(
|
||||
'users',
|
||||
prismaState.defaultUser.id,
|
||||
'artwork',
|
||||
'sha-missing-artwork.png',
|
||||
),
|
||||
mimeType: 'image/png',
|
||||
width: 1,
|
||||
height: 1,
|
||||
fileSizeBytes: BigInt(10),
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
track: {
|
||||
userId: prismaState.defaultUser.id,
|
||||
},
|
||||
tracks: [{ userId: prismaState.defaultUser.id }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -638,6 +679,8 @@ describe('Velody API wiring (e2e)', () => {
|
||||
const primaryTrackId = randomUUID();
|
||||
const primaryAssetId = randomUUID();
|
||||
const primaryArtworkId = randomUUID();
|
||||
const placeholderTrackId = randomUUID();
|
||||
const placeholderAssetId = randomUUID();
|
||||
const secondaryTrackId = randomUUID();
|
||||
const secondaryAssetId = randomUUID();
|
||||
|
||||
@@ -669,13 +712,29 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
prismaState.artworkAssets.set(primaryArtworkId, {
|
||||
id: primaryArtworkId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
sha256: 'artwork-sha-default',
|
||||
storageKey: `library/artwork/${primaryArtworkId}.png`,
|
||||
storageKey: `users/${prismaState.defaultUser.id}/artwork/artwork-sha-default.png`,
|
||||
mimeType: 'image/png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fileSizeBytes: BigInt(128),
|
||||
createdAt: new Date('2026-05-29T08:00:30.000Z'),
|
||||
tracks: [{ userId: prismaState.defaultUser.id }],
|
||||
});
|
||||
prismaState.audioAssets.set(placeholderAssetId, {
|
||||
id: placeholderAssetId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
trackId: placeholderTrackId,
|
||||
sha256: 'sha-no-artwork',
|
||||
storageKey: 'users/default/audio/sha-no-artwork.mp3',
|
||||
originalFilename: 'no-artwork.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(64),
|
||||
durationMs: 201000,
|
||||
sourceDeviceId: primaryDevice.deviceId,
|
||||
createdAt: new Date('2026-05-29T08:00:45.000Z'),
|
||||
});
|
||||
prismaState.audioAssets.set(secondaryAssetId, {
|
||||
id: secondaryAssetId,
|
||||
@@ -730,6 +789,25 @@ describe('Velody API wiring (e2e)', () => {
|
||||
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:03:00.000Z'),
|
||||
});
|
||||
prismaState.tracks.set(placeholderTrackId, {
|
||||
id: placeholderTrackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: placeholderAssetId,
|
||||
artworkAssetId: null,
|
||||
title: 'No Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: 201000,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:00:45.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:02:30.000Z'),
|
||||
});
|
||||
|
||||
const response = await libraryController.getTracks({
|
||||
deviceId: primaryDevice.deviceId,
|
||||
@@ -754,6 +832,17 @@ describe('Velody API wiring (e2e)', () => {
|
||||
height: 512,
|
||||
},
|
||||
},
|
||||
{
|
||||
trackId: placeholderTrackId,
|
||||
title: 'No Artwork Track',
|
||||
artist: 'Velody',
|
||||
durationSeconds: 201,
|
||||
sha256: 'sha-no-artwork',
|
||||
assetId: placeholderAssetId,
|
||||
createdAt: '2026-05-29T08:00:45.000Z',
|
||||
updatedAt: '2026-05-29T08:02:30.000Z',
|
||||
artwork: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -832,7 +921,194 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(duplicatePrepare.status).toBe('exists');
|
||||
expect(duplicatePrepare.uploadId).toBeDefined();
|
||||
expect(prismaState.audioAssets.size).toBe(1);
|
||||
expect(prismaState.libraryEvents.size).toBe(1);
|
||||
});
|
||||
|
||||
it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Artwork Upload Mac',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const bytes = sampleMp3Bytes('e2e-upload-artwork');
|
||||
const artworkBytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const artworkSha256 = sha256Hex(artworkBytes);
|
||||
|
||||
const prepareResponse = await uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-upload-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
|
||||
expect(prepareResponse.status).toBe('upload_required');
|
||||
|
||||
const uploadResponse = await uploadsService.uploadFile(
|
||||
prepareResponse.uploadId!,
|
||||
createUploadRequest(bytes),
|
||||
);
|
||||
|
||||
expect(uploadResponse.status).toBe('COMPLETED');
|
||||
|
||||
const finalizeResponse = await uploadsController.finalize(
|
||||
prepareResponse.uploadId!,
|
||||
{
|
||||
title: 'Uploaded Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
durationMs: 222000,
|
||||
artwork: {
|
||||
dataBase64: artworkBytes.toString('base64'),
|
||||
sha256: artworkSha256,
|
||||
mimeType: 'image/png',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(finalizeResponse.trackId).toBeDefined();
|
||||
expect(prismaState.artworkAssets.size).toBe(1);
|
||||
|
||||
const remoteLibrary = await libraryController.getTracks({
|
||||
deviceId: registerResponse.deviceId,
|
||||
});
|
||||
expect(remoteLibrary.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId: finalizeResponse.trackId,
|
||||
artwork: {
|
||||
artworkId: expect.any(String),
|
||||
sha256: artworkSha256,
|
||||
mimeType: 'image/png',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const artworkAsset = [...prismaState.artworkAssets.values()][0];
|
||||
const storedArtworkBytes = await readFile(
|
||||
join(
|
||||
storageRoot,
|
||||
'users',
|
||||
prismaState.defaultUser.id,
|
||||
'artwork',
|
||||
`${artworkSha256}.png`,
|
||||
),
|
||||
);
|
||||
expect(storedArtworkBytes.equals(artworkBytes)).toBe(true);
|
||||
|
||||
const headers = new Map<string, string>();
|
||||
const responseMock = {
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name.toLowerCase(), String(value));
|
||||
},
|
||||
} as any;
|
||||
|
||||
const streamable = await artworkController.download(
|
||||
artworkAsset.id,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
responseMock,
|
||||
);
|
||||
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
|
||||
|
||||
expect(downloadedArtworkBytes.equals(artworkBytes)).toBe(true);
|
||||
expect(headers.get('content-type')).toBe('image/png');
|
||||
});
|
||||
|
||||
it('attaches artwork through the deduped prepare exists path and returns non-null remote artwork metadata', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Deduped Artwork Upload Mac',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const bytes = sampleMp3Bytes('e2e-deduped-artwork');
|
||||
const artworkBytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const artworkSha256 = sha256Hex(artworkBytes);
|
||||
|
||||
const firstPrepare = await uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
|
||||
expect(firstPrepare.status).toBe('upload_required');
|
||||
|
||||
const uploadResponse = await uploadsService.uploadFile(
|
||||
firstPrepare.uploadId!,
|
||||
createUploadRequest(bytes),
|
||||
);
|
||||
|
||||
expect(uploadResponse.status).toBe('COMPLETED');
|
||||
|
||||
const firstFinalize = await uploadsController.finalize(
|
||||
firstPrepare.uploadId!,
|
||||
{
|
||||
title: 'Deduped Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
durationMs: 222000,
|
||||
},
|
||||
);
|
||||
|
||||
const secondPrepare = await uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
|
||||
expect(secondPrepare.status).toBe('exists');
|
||||
expect(secondPrepare.uploadId).toBeDefined();
|
||||
expect(prismaState.audioAssets.size).toBe(1);
|
||||
|
||||
const secondFinalize = await uploadsController.finalize(
|
||||
secondPrepare.uploadId!,
|
||||
{
|
||||
title: 'Deduped Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
durationMs: 222000,
|
||||
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(prismaState.artworkAssets.size).toBe(1);
|
||||
|
||||
const remoteLibrary = await libraryController.getTracks({
|
||||
deviceId: registerResponse.deviceId,
|
||||
});
|
||||
expect(remoteLibrary.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId: firstFinalize.trackId,
|
||||
artwork: {
|
||||
artworkId: expect.any(String),
|
||||
sha256: artworkSha256,
|
||||
mimeType: 'image/png',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user