velody/backend/test/e2e/app.e2e-spec.ts

538 lines
16 KiB
TypeScript

import { randomUUID, createHash } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../src/app.module';
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;
}
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 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,
}));
}),
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;
}),
},
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,
uploadSessions,
libraryEvents,
},
};
}
describe('Velody API wiring (e2e)', () => {
let app: INestApplication;
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();
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('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 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.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: null,
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',
},
],
});
});
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);
});
});