839 lines
25 KiB
TypeScript
839 lines
25 KiB
TypeScript
import { randomUUID, createHash } from 'node:crypto';
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import {
|
|
ForbiddenException,
|
|
INestApplication,
|
|
NotFoundException,
|
|
ValidationPipe,
|
|
VersioningType,
|
|
} from '@nestjs/common';
|
|
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';
|
|
import { LibraryController } from '../../src/modules/library/library.controller';
|
|
import { LibraryTracksQueryDto } from '../../src/modules/library/library.dto';
|
|
import { SyncController } from '../../src/modules/sync/sync.controller';
|
|
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
|
|
import { UploadsService } from '../../src/modules/uploads/uploads.service';
|
|
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
|
|
|
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 createUploadRequest(data: Buffer): any {
|
|
const request = Readable.from([data]) as any;
|
|
request.headers = {
|
|
'content-type': 'audio/mpeg',
|
|
'content-length': String(data.length),
|
|
};
|
|
return request;
|
|
}
|
|
|
|
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunkValue of stream) {
|
|
chunks.push(
|
|
Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue),
|
|
);
|
|
}
|
|
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
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,
|
|
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: {
|
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
|
const record = {
|
|
id: randomUUID(),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...data,
|
|
};
|
|
devices.set(record.id, record);
|
|
return record;
|
|
}),
|
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
const device = devices.get(where.id) ?? null;
|
|
if (!device) {
|
|
return null;
|
|
}
|
|
|
|
if (where.id && typeof where.id === 'string') {
|
|
return device;
|
|
}
|
|
|
|
return device;
|
|
}),
|
|
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: {
|
|
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;
|
|
const assetMatches = where?.primaryAudioAssetId?.not
|
|
? track.primaryAudioAssetId != null
|
|
: true;
|
|
return userMatches && statusMatches && assetMatches;
|
|
})
|
|
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime())
|
|
.map((track) => ({
|
|
...track,
|
|
primaryAudioAsset: track.primaryAudioAssetId
|
|
? audioAssets.get(track.primaryAudioAssetId) ?? null
|
|
: null,
|
|
artworkAsset: track.artworkAssetId
|
|
? artworkAssets.get(track.artworkAssetId) ?? null
|
|
: null,
|
|
}));
|
|
}),
|
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
return tracks.get(where.id) ?? null;
|
|
}),
|
|
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: {
|
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
return artworkAssets.get(where.id) ?? null;
|
|
}),
|
|
},
|
|
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;
|
|
}),
|
|
},
|
|
};
|
|
|
|
return {
|
|
prismaMock,
|
|
state: {
|
|
defaultUser,
|
|
devices,
|
|
tracks,
|
|
audioAssets,
|
|
artworkAssets,
|
|
uploadSessions,
|
|
libraryEvents,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('Velody API wiring (e2e)', () => {
|
|
let app: INestApplication;
|
|
let assetsController: AssetsController;
|
|
let artworkController: ArtworkController;
|
|
let healthController: HealthController;
|
|
let devicesController: DevicesController;
|
|
let libraryController: LibraryController;
|
|
let syncController: SyncController;
|
|
let uploadsController: UploadsController;
|
|
let uploadsService: UploadsService;
|
|
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
|
let storageRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
const { prismaMock, state } = createPrismaMock();
|
|
prismaState = state;
|
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
|
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(AppConfigService)
|
|
.useValue({
|
|
appVersion: '0.1.0',
|
|
maxUploadSizeBytes: 1024 * 1024 * 1024,
|
|
storageRoot,
|
|
})
|
|
.overrideProvider(PrismaService)
|
|
.useValue(prismaMock)
|
|
.compile();
|
|
|
|
app = moduleRef.createNestApplication();
|
|
app.setGlobalPrefix('api');
|
|
app.enableVersioning({ type: VersioningType.URI });
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
await app.init();
|
|
|
|
assetsController = moduleRef.get(AssetsController);
|
|
artworkController = moduleRef.get(ArtworkController);
|
|
healthController = moduleRef.get(HealthController);
|
|
devicesController = moduleRef.get(DevicesController);
|
|
libraryController = moduleRef.get(LibraryController);
|
|
syncController = moduleRef.get(SyncController);
|
|
uploadsController = moduleRef.get(UploadsController);
|
|
uploadsService = moduleRef.get(UploadsService);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
|
|
await rm(storageRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns health information', async () => {
|
|
const response = await healthController.getHealth();
|
|
|
|
expect(response.database.status).toBe('up');
|
|
expect(response.storage.status).toBe('up');
|
|
expect(response.version).toBe('0.1.0');
|
|
});
|
|
|
|
it('registers a device and accepts heartbeat', async () => {
|
|
const registerResponse = await devicesController.register({
|
|
platform: 'MACOS',
|
|
deviceName: 'Diya MacBook Pro',
|
|
appVersion: '0.1.0',
|
|
});
|
|
|
|
expect(registerResponse.deviceId).toBeDefined();
|
|
expect(registerResponse.bootstrapToken).toBeDefined();
|
|
|
|
const heartbeatResponse = await devicesController.heartbeat({
|
|
deviceId: registerResponse.deviceId,
|
|
appVersion: '0.1.1',
|
|
});
|
|
|
|
expect(heartbeatResponse.ok).toBe(true);
|
|
});
|
|
|
|
it('returns sync bootstrap and changes payloads', async () => {
|
|
const bootstrapResponse = await syncController.bootstrap();
|
|
const changesResponse = await syncController.changes({ after: '0' });
|
|
|
|
expect(bootstrapResponse.tracks).toEqual([]);
|
|
expect(changesResponse.events).toEqual([]);
|
|
expect(changesResponse.nextCursor).toBe('0');
|
|
});
|
|
|
|
it('downloads audio asset bytes for the owning device user', async () => {
|
|
const registerResponse = await devicesController.register({
|
|
platform: 'IPHONE',
|
|
deviceName: 'Playback iPhone',
|
|
appVersion: '0.1.0',
|
|
});
|
|
const assetId = randomUUID();
|
|
const trackId = randomUUID();
|
|
const bytes = sampleMp3Bytes('owner-download');
|
|
const storageKey = join(
|
|
'users',
|
|
prismaState.defaultUser.id,
|
|
'audio',
|
|
'owner-download.mp3',
|
|
);
|
|
|
|
prismaState.audioAssets.set(assetId, {
|
|
id: assetId,
|
|
userId: prismaState.defaultUser.id,
|
|
trackId,
|
|
sha256: sha256Hex(bytes),
|
|
storageKey,
|
|
originalFilename: 'owner-download.mp3',
|
|
mimeType: 'audio/mpeg',
|
|
fileExtension: 'mp3',
|
|
fileSizeBytes: BigInt(bytes.length),
|
|
durationMs: 180000,
|
|
sourceDeviceId: registerResponse.deviceId,
|
|
createdAt: new Date('2026-05-29T08:00: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 assetsController.download(
|
|
assetId,
|
|
{ deviceId: registerResponse.deviceId },
|
|
responseMock,
|
|
);
|
|
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
|
|
|
expect(downloadedBytes.equals(bytes)).toBe(true);
|
|
expect(headers.get('content-type')).toBe('audio/mpeg');
|
|
expect(headers.get('content-length')).toBe(String(bytes.length));
|
|
});
|
|
|
|
it('rejects unauthorized asset download requests for another user asset', async () => {
|
|
const registerResponse = await devicesController.register({
|
|
platform: 'IPHONE',
|
|
deviceName: 'Playback iPhone',
|
|
appVersion: '0.1.0',
|
|
});
|
|
const assetId = randomUUID();
|
|
const otherUserId = randomUUID();
|
|
|
|
prismaState.audioAssets.set(assetId, {
|
|
id: assetId,
|
|
userId: otherUserId,
|
|
trackId: randomUUID(),
|
|
sha256: 'sha-other',
|
|
storageKey: join('users', otherUserId, 'audio', 'other.mp3'),
|
|
originalFilename: 'other.mp3',
|
|
mimeType: 'audio/mpeg',
|
|
fileExtension: 'mp3',
|
|
fileSizeBytes: BigInt(10),
|
|
durationMs: 180000,
|
|
sourceDeviceId: randomUUID(),
|
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
});
|
|
|
|
await expect(
|
|
assetsController.download(
|
|
assetId,
|
|
{ deviceId: registerResponse.deviceId },
|
|
{ setHeader() {} } as any,
|
|
),
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('handles missing audio asset files cleanly', async () => {
|
|
const registerResponse = await devicesController.register({
|
|
platform: 'IPHONE',
|
|
deviceName: 'Playback iPhone',
|
|
appVersion: '0.1.0',
|
|
});
|
|
const assetId = randomUUID();
|
|
|
|
prismaState.audioAssets.set(assetId, {
|
|
id: assetId,
|
|
userId: prismaState.defaultUser.id,
|
|
trackId: randomUUID(),
|
|
sha256: 'sha-missing-file',
|
|
storageKey: join(
|
|
'users',
|
|
prismaState.defaultUser.id,
|
|
'audio',
|
|
'missing-file.mp3',
|
|
),
|
|
originalFilename: 'missing-file.mp3',
|
|
mimeType: 'audio/mpeg',
|
|
fileExtension: 'mp3',
|
|
fileSizeBytes: BigInt(10),
|
|
durationMs: 180000,
|
|
sourceDeviceId: registerResponse.deviceId,
|
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
});
|
|
|
|
await expect(
|
|
assetsController.download(
|
|
assetId,
|
|
{ deviceId: registerResponse.deviceId },
|
|
{ setHeader() {} } as any,
|
|
),
|
|
).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,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
});
|
|
|
|
await expect(
|
|
validationPipe.transform(
|
|
{ deviceId: 'not-a-uuid' },
|
|
{
|
|
type: 'query',
|
|
metatype: AssetDownloadQueryDto,
|
|
data: '',
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
response: {
|
|
message: expect.arrayContaining(['deviceId must be a UUID']),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns remote library metadata for the requesting device owner', async () => {
|
|
const primaryDevice = await devicesController.register({
|
|
platform: 'IPHONE',
|
|
deviceName: 'iPhone',
|
|
appVersion: '0.1.0',
|
|
});
|
|
const secondUserId = randomUUID();
|
|
const secondaryDeviceId = randomUUID();
|
|
const primaryTrackId = randomUUID();
|
|
const primaryAssetId = randomUUID();
|
|
const primaryArtworkId = randomUUID();
|
|
const secondaryTrackId = randomUUID();
|
|
const secondaryAssetId = randomUUID();
|
|
|
|
prismaState.devices.set(secondaryDeviceId, {
|
|
id: secondaryDeviceId,
|
|
userId: secondUserId,
|
|
platform: 'IPHONE',
|
|
deviceName: 'Other iPhone',
|
|
appVersion: '0.1.0',
|
|
installTokenHash: 'other-device-hash',
|
|
lastSeenAt: new Date(),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
prismaState.audioAssets.set(primaryAssetId, {
|
|
id: primaryAssetId,
|
|
userId: prismaState.defaultUser.id,
|
|
trackId: primaryTrackId,
|
|
sha256: 'sha-default',
|
|
storageKey: 'users/default/audio/sha-default.mp3',
|
|
originalFilename: 'default.mp3',
|
|
mimeType: 'audio/mpeg',
|
|
fileExtension: 'mp3',
|
|
fileSizeBytes: BigInt(42),
|
|
durationMs: 245000,
|
|
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,
|
|
trackId: secondaryTrackId,
|
|
sha256: 'sha-other-user',
|
|
storageKey: 'users/other/audio/sha-other-user.mp3',
|
|
originalFilename: 'other.mp3',
|
|
mimeType: 'audio/mpeg',
|
|
fileExtension: 'mp3',
|
|
fileSizeBytes: BigInt(24),
|
|
durationMs: 180000,
|
|
sourceDeviceId: secondaryDeviceId,
|
|
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
|
});
|
|
|
|
prismaState.tracks.set(primaryTrackId, {
|
|
id: primaryTrackId,
|
|
userId: prismaState.defaultUser.id,
|
|
primaryAudioAssetId: primaryAssetId,
|
|
artworkAssetId: primaryArtworkId,
|
|
title: 'Default User Track',
|
|
artist: 'Velody',
|
|
album: null,
|
|
albumArtist: null,
|
|
genre: null,
|
|
discNumber: null,
|
|
trackNumber: null,
|
|
year: null,
|
|
durationMs: 245000,
|
|
status: 'ACTIVE',
|
|
deletedAt: null,
|
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
|
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
|
});
|
|
prismaState.tracks.set(secondaryTrackId, {
|
|
id: secondaryTrackId,
|
|
userId: secondUserId,
|
|
primaryAudioAssetId: secondaryAssetId,
|
|
artworkAssetId: null,
|
|
title: 'Other User Track',
|
|
artist: 'Elsewhere',
|
|
album: null,
|
|
albumArtist: null,
|
|
genre: null,
|
|
discNumber: null,
|
|
trackNumber: null,
|
|
year: null,
|
|
durationMs: 180000,
|
|
status: 'ACTIVE',
|
|
deletedAt: null,
|
|
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
|
updatedAt: new Date('2026-05-29T08:03:00.000Z'),
|
|
});
|
|
|
|
const response = await libraryController.getTracks({
|
|
deviceId: primaryDevice.deviceId,
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
tracks: [
|
|
{
|
|
trackId: primaryTrackId,
|
|
title: 'Default User Track',
|
|
artist: 'Velody',
|
|
durationSeconds: 245,
|
|
sha256: 'sha-default',
|
|
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,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects an invalid remote library device id query', async () => {
|
|
const validationPipe = new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
});
|
|
|
|
await expect(
|
|
validationPipe.transform(
|
|
{ deviceId: 'not-a-uuid' },
|
|
{
|
|
type: 'query',
|
|
metatype: LibraryTracksQueryDto,
|
|
data: '',
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
response: {
|
|
message: expect.arrayContaining(['deviceId must be a UUID']),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
|
const registerResponse = await devicesController.register({
|
|
platform: 'MACOS',
|
|
deviceName: 'Upload Mac',
|
|
appVersion: '0.1.0',
|
|
});
|
|
const bytes = sampleMp3Bytes('e2e-upload');
|
|
const sha256 = sha256Hex(bytes);
|
|
|
|
const prepareResponse = await uploadsController.prepare({
|
|
deviceId: registerResponse.deviceId,
|
|
sha256,
|
|
originalFilename: 'e2e-upload.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 Track',
|
|
artist: 'Velody',
|
|
album: 'Milestone 6',
|
|
durationMs: 222000,
|
|
},
|
|
);
|
|
|
|
expect(finalizeResponse.trackId).toBeDefined();
|
|
expect(finalizeResponse.assetId).toBeDefined();
|
|
|
|
const storedBytes = await readFile(
|
|
join(storageRoot, 'users', prismaState.defaultUser.id, 'audio', `${sha256}.mp3`),
|
|
);
|
|
expect(storedBytes.equals(bytes)).toBe(true);
|
|
|
|
const duplicatePrepare = await uploadsController.prepare({
|
|
deviceId: registerResponse.deviceId,
|
|
sha256,
|
|
originalFilename: 'e2e-upload.mp3',
|
|
sizeBytes: bytes.length,
|
|
});
|
|
|
|
expect(duplicatePrepare.status).toBe('exists');
|
|
expect(prismaState.audioAssets.size).toBe(1);
|
|
expect(prismaState.libraryEvents.size).toBe(1);
|
|
});
|
|
});
|