Implement artwork download and cache
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from './modules/assets/assets.module';
|
||||
import { ArtworkModule } from './modules/artwork/artwork.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DevicesModule } from './modules/devices/devices.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
@@ -11,6 +12,7 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AssetsModule,
|
||||
ArtworkModule,
|
||||
HealthModule,
|
||||
DevicesModule,
|
||||
UploadsModule,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { AssetDownloadQueryDto } from '../assets/assets.dto';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
@ApiTags('artwork')
|
||||
@Controller({
|
||||
path: 'artwork',
|
||||
version: '1',
|
||||
})
|
||||
export class ArtworkController {
|
||||
constructor(private readonly artworkService: ArtworkService) {}
|
||||
|
||||
@Get(':artworkId/download')
|
||||
@ApiProduces('image/*')
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
})
|
||||
async download(
|
||||
@Param('artworkId') artworkId: string,
|
||||
@Query() query: AssetDownloadQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const download = await this.artworkService.getOwnedArtworkDownload(
|
||||
artworkId,
|
||||
query.deviceId,
|
||||
);
|
||||
|
||||
response.setHeader('Content-Type', download.mimeType);
|
||||
response.setHeader('Content-Length', String(download.contentLength));
|
||||
|
||||
return new StreamableFile(createReadStream(download.filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { ArtworkController } from './artwork.controller';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule],
|
||||
controllers: [ArtworkController],
|
||||
providers: [ArtworkService],
|
||||
})
|
||||
export class ArtworkModule {}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||
|
||||
function createPrismaMock() {
|
||||
const devices = new Map<string, any>();
|
||||
const artworkAssets = new Map<string, any>();
|
||||
|
||||
return {
|
||||
prismaMock: {
|
||||
device: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return devices.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
artworkAsset: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return artworkAssets.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService,
|
||||
state: {
|
||||
devices,
|
||||
artworkAssets,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAppConfig(storageRoot: string): AppConfigService {
|
||||
return {
|
||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||
storageRoot,
|
||||
} as AppConfigService;
|
||||
}
|
||||
|
||||
describe('ArtworkService', () => {
|
||||
let service: ArtworkService;
|
||||
let state: MockState;
|
||||
let storageRoot: string;
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mock = createPrismaMock();
|
||||
state = mock.state;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
service = new ArtworkService(mock.prismaMock, storageService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
const storageKey = join('library', 'artwork', `${artworkId}.png`);
|
||||
const bytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
track: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const filePath = storageService.resolve(storageKey);
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, bytes);
|
||||
|
||||
const download = await service.getOwnedArtworkDownload(artworkId, deviceId);
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(bytes.length);
|
||||
expect(download.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('rejects download attempts from a different user device', async () => {
|
||||
const ownerId = randomUUID();
|
||||
const otherUserId = randomUUID();
|
||||
const ownerDeviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
storageKey: join('library', 'artwork', `${artworkId}.jpg`),
|
||||
mimeType: 'image/jpeg',
|
||||
track: {
|
||||
userId: ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, ownerDeviceId),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('returns not found when the artwork file is missing from storage', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
storageKey: join('library', 'artwork', `${artworkId}.png`),
|
||||
mimeType: 'image/png',
|
||||
track: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, deviceId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the artwork asset does not exist', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(randomUUID(), deviceId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
|
||||
export interface ArtworkDownload {
|
||||
filePath: string;
|
||||
contentLength: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ArtworkService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
) {}
|
||||
|
||||
async getOwnedArtworkDownload(
|
||||
artworkId: string,
|
||||
deviceId: string,
|
||||
): Promise<ArtworkDownload> {
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||
where: { id: artworkId },
|
||||
select: {
|
||||
storageKey: true,
|
||||
mimeType: true,
|
||||
track: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!artwork || !artwork.track) {
|
||||
throw new NotFoundException('Artwork not found');
|
||||
}
|
||||
|
||||
if (artwork.track.userId !== device.userId) {
|
||||
throw new ForbiddenException('Artwork does not belong to this device user.');
|
||||
}
|
||||
|
||||
const filePath = this.storageService.resolve(artwork.storageKey);
|
||||
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
|
||||
if (!fileStats.isFile()) {
|
||||
throw new NotFoundException('Artwork file not found');
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
contentLength: fileStats.size,
|
||||
mimeType: artwork.mimeType,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NotFoundException('Artwork file not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,26 @@ export class LibraryTracksQueryDto {
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
export class RemoteArtworkDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
artworkId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example:
|
||||
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
})
|
||||
sha256!: string;
|
||||
|
||||
@ApiProperty({ example: 'image/jpeg' })
|
||||
mimeType!: string;
|
||||
|
||||
@ApiProperty({ example: 512, required: false, nullable: true })
|
||||
width!: number | null;
|
||||
|
||||
@ApiProperty({ example: 512, required: false, nullable: true })
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
export class RemoteLibraryTrackDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
trackId!: string;
|
||||
@@ -34,6 +54,13 @@ export class RemoteLibraryTrackDto {
|
||||
|
||||
@ApiProperty({ example: '2026-05-29T08:05:00.000Z' })
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: RemoteArtworkDto,
|
||||
required: false,
|
||||
nullable: true,
|
||||
})
|
||||
artwork!: RemoteArtworkDto | null;
|
||||
}
|
||||
|
||||
export class LibraryTracksResponseDto {
|
||||
|
||||
@@ -9,6 +9,7 @@ function createPrismaMock() {
|
||||
const devices = new Map<string, any>();
|
||||
const tracks = new Map<string, any>();
|
||||
const audioAssets = new Map<string, any>();
|
||||
const artworkAssets = new Map<string, any>();
|
||||
|
||||
return {
|
||||
prismaMock: {
|
||||
@@ -35,6 +36,9 @@ function createPrismaMock() {
|
||||
primaryAudioAsset: track.primaryAudioAssetId
|
||||
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
||||
: null,
|
||||
artworkAsset: track.artworkAssetId
|
||||
? artworkAssets.get(track.artworkAssetId) ?? null
|
||||
: null,
|
||||
}));
|
||||
}),
|
||||
},
|
||||
@@ -43,6 +47,7 @@ function createPrismaMock() {
|
||||
devices,
|
||||
tracks,
|
||||
audioAssets,
|
||||
artworkAssets,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,6 +83,7 @@ describe('LibraryService', () => {
|
||||
const ownerDeviceId = randomUUID();
|
||||
const ownerTrackId = randomUUID();
|
||||
const ownerAssetId = randomUUID();
|
||||
const ownerArtworkId = randomUUID();
|
||||
const secondOwnerTrackId = randomUUID();
|
||||
const secondOwnerAssetId = randomUUID();
|
||||
const otherTrackId = randomUUID();
|
||||
@@ -90,6 +96,13 @@ describe('LibraryService', () => {
|
||||
sha256: 'sha-owner-a',
|
||||
durationMs: 181000,
|
||||
});
|
||||
state.artworkAssets.set(ownerArtworkId, {
|
||||
id: ownerArtworkId,
|
||||
sha256: 'artwork-sha-owner-a',
|
||||
mimeType: 'image/png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
});
|
||||
state.audioAssets.set(secondOwnerAssetId, {
|
||||
id: secondOwnerAssetId,
|
||||
sha256: 'sha-owner-b',
|
||||
@@ -120,6 +133,7 @@ describe('LibraryService', () => {
|
||||
durationMs: 181000,
|
||||
status: 'ACTIVE',
|
||||
primaryAudioAssetId: ownerAssetId,
|
||||
artworkAssetId: ownerArtworkId,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||
});
|
||||
@@ -147,6 +161,13 @@ describe('LibraryService', () => {
|
||||
assetId: ownerAssetId,
|
||||
createdAt: '2026-05-29T08:00:00.000Z',
|
||||
updatedAt: '2026-05-29T08:01:00.000Z',
|
||||
artwork: {
|
||||
artworkId: ownerArtworkId,
|
||||
sha256: 'artwork-sha-owner-a',
|
||||
mimeType: 'image/png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
},
|
||||
},
|
||||
{
|
||||
trackId: secondOwnerTrackId,
|
||||
@@ -157,6 +178,7 @@ describe('LibraryService', () => {
|
||||
assetId: secondOwnerAssetId,
|
||||
createdAt: '2026-05-29T08:05:00.000Z',
|
||||
updatedAt: '2026-05-29T08:06:00.000Z',
|
||||
artwork: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,15 @@ export class LibraryService {
|
||||
durationMs: true,
|
||||
},
|
||||
},
|
||||
artworkAsset: {
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
mimeType: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,6 +104,15 @@ export class LibraryService {
|
||||
assetId: track.primaryAudioAsset.id,
|
||||
createdAt: track.createdAt.toISOString(),
|
||||
updatedAt: track.updatedAt.toISOString(),
|
||||
artwork: track.artworkAsset
|
||||
? {
|
||||
artworkId: track.artworkAsset.id,
|
||||
sha256: track.artworkAsset.sha256,
|
||||
mimeType: track.artworkAsset.mimeType,
|
||||
width: track.artworkAsset.width,
|
||||
height: track.artworkAsset.height,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
|
||||
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||
import { HealthController } from '../../src/modules/health/health.controller';
|
||||
@@ -62,6 +63,7 @@ function createPrismaMock() {
|
||||
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;
|
||||
@@ -133,6 +135,9 @@ function createPrismaMock() {
|
||||
primaryAudioAsset: track.primaryAudioAssetId
|
||||
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
||||
: null,
|
||||
artworkAsset: track.artworkAssetId
|
||||
? artworkAssets.get(track.artworkAssetId) ?? null
|
||||
: null,
|
||||
}));
|
||||
}),
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
@@ -209,6 +214,11 @@ function createPrismaMock() {
|
||||
return updated;
|
||||
}),
|
||||
},
|
||||
artworkAsset: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return artworkAssets.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
uploadSession: {
|
||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||
const now = new Date();
|
||||
@@ -266,6 +276,7 @@ function createPrismaMock() {
|
||||
devices,
|
||||
tracks,
|
||||
audioAssets,
|
||||
artworkAssets,
|
||||
uploadSessions,
|
||||
libraryEvents,
|
||||
},
|
||||
@@ -275,6 +286,7 @@ function createPrismaMock() {
|
||||
describe('Velody API wiring (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let assetsController: AssetsController;
|
||||
let artworkController: ArtworkController;
|
||||
let healthController: HealthController;
|
||||
let devicesController: DevicesController;
|
||||
let libraryController: LibraryController;
|
||||
@@ -315,6 +327,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
await app.init();
|
||||
|
||||
assetsController = moduleRef.get(AssetsController);
|
||||
artworkController = moduleRef.get(ArtworkController);
|
||||
healthController = moduleRef.get(HealthController);
|
||||
devicesController = moduleRef.get(DevicesController);
|
||||
libraryController = moduleRef.get(LibraryController);
|
||||
@@ -490,6 +503,107 @@ describe('Velody API wiring (e2e)', () => {
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('downloads artwork bytes for the owning device user with the stored image mime type', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Artwork iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const artworkId = randomUUID();
|
||||
const trackId = randomUUID();
|
||||
const bytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const storageKey = join('library', 'artwork', `${artworkId}.png`);
|
||||
|
||||
prismaState.artworkAssets.set(artworkId, {
|
||||
id: artworkId,
|
||||
sha256: sha256Hex(bytes),
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
width: 1,
|
||||
height: 1,
|
||||
fileSizeBytes: BigInt(bytes.length),
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
track: {
|
||||
userId: prismaState.defaultUser.id,
|
||||
},
|
||||
});
|
||||
prismaState.tracks.set(trackId, {
|
||||
id: trackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: null,
|
||||
artworkAssetId: artworkId,
|
||||
title: 'Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: null,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
|
||||
const filePath = join(storageRoot, storageKey);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, bytes);
|
||||
|
||||
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(
|
||||
artworkId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
responseMock,
|
||||
);
|
||||
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||
|
||||
expect(downloadedBytes.equals(bytes)).toBe(true);
|
||||
expect(headers.get('content-type')).toBe('image/png');
|
||||
expect(headers.get('content-length')).toBe(String(bytes.length));
|
||||
});
|
||||
|
||||
it('returns not found when the requested artwork file is missing', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Artwork iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const artworkId = randomUUID();
|
||||
|
||||
prismaState.artworkAssets.set(artworkId, {
|
||||
id: artworkId,
|
||||
sha256: 'sha-missing-artwork',
|
||||
storageKey: join('library', 'artwork', `${artworkId}.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,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
artworkController.download(
|
||||
artworkId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects an invalid asset download device id query', async () => {
|
||||
const validationPipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -523,6 +637,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
const secondaryDeviceId = randomUUID();
|
||||
const primaryTrackId = randomUUID();
|
||||
const primaryAssetId = randomUUID();
|
||||
const primaryArtworkId = randomUUID();
|
||||
const secondaryTrackId = randomUUID();
|
||||
const secondaryAssetId = randomUUID();
|
||||
|
||||
@@ -552,6 +667,16 @@ describe('Velody API wiring (e2e)', () => {
|
||||
sourceDeviceId: primaryDevice.deviceId,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
});
|
||||
prismaState.artworkAssets.set(primaryArtworkId, {
|
||||
id: primaryArtworkId,
|
||||
sha256: 'artwork-sha-default',
|
||||
storageKey: `library/artwork/${primaryArtworkId}.png`,
|
||||
mimeType: 'image/png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fileSizeBytes: BigInt(128),
|
||||
createdAt: new Date('2026-05-29T08:00:30.000Z'),
|
||||
});
|
||||
prismaState.audioAssets.set(secondaryAssetId, {
|
||||
id: secondaryAssetId,
|
||||
userId: secondUserId,
|
||||
@@ -571,7 +696,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
id: primaryTrackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: primaryAssetId,
|
||||
artworkAssetId: null,
|
||||
artworkAssetId: primaryArtworkId,
|
||||
title: 'Default User Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
@@ -621,6 +746,13 @@ describe('Velody API wiring (e2e)', () => {
|
||||
assetId: primaryAssetId,
|
||||
createdAt: '2026-05-29T08:00:00.000Z',
|
||||
updatedAt: '2026-05-29T08:02:00.000Z',
|
||||
artwork: {
|
||||
artworkId: primaryArtworkId,
|
||||
sha256: 'artwork-sha-default',
|
||||
mimeType: 'image/png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user