Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions backend/src/mount-waveform/tracks.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { WaveformService } from '../waveform/waveform.service';
import { TrackEntity } from './track.entity';
import { TracksService } from './tracks.service';
Comment on lines +4 to +6
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the actual location of track.entity.ts and waveform.service.ts
echo "=== Searching for track.entity.ts ==="
fd 'track.entity.ts'

echo "=== Searching for waveform.service.ts ==="
fd 'waveform.service.ts'

echo "=== Directory structure of mount-waveform ==="
tree backend/src/mount-waveform 2>/dev/null || find backend/src/mount-waveform -type f -name "*.ts" 2>/dev/null

Repository: OlufunbiIK/tip-tune

Length of output: 723


🏁 Script executed:

cat -n backend/src/mount-waveform/tracks.service.spec.ts | head -20

Repository: OlufunbiIK/tip-tune

Length of output: 780


Fix import path for TrackEntity – module not found.

Line 5 imports TrackEntity from './track.entity', but the file does not exist in the mount-waveform directory. The actual location is backend/src/tracks/entities/track.entity.ts. Change the import to:

import { TrackEntity } from '../../tracks/entities/track.entity';

This is causing the pipeline failure TS2307: Cannot find module './track.entity'.

🧰 Tools
🪛 GitHub Actions: Backend CI

[error] 5-5: TS2307: Cannot find module './track.entity' or its corresponding type declarations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/mount-waveform/tracks.service.spec.ts` around lines 4 - 6, The
import for TrackEntity in tracks.service.spec.ts is pointing to a non-existent
'./track.entity'; update the import to reference the actual module location by
changing the TrackEntity import to the correct relative path (replace the
'./track.entity' import with the path leading to tracks/entities/track.entity),
so the TracksService tests can resolve TrackEntity correctly; ensure the import
line importing TrackEntity matches the module export name and compile.


// ─── Mocks ────────────────────────────────────────────────────────────────────

const trackRepoMock = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};

const waveformServiceMock = {
enqueueForTrack: jest.fn(),
};

const makeTrack = (overrides: Partial<TrackEntity> = {}): TrackEntity =>
Object.assign(new TrackEntity(), {
id: 'track-uuid',
title: 'Test Track',
audioFilePath: '/uploads/test.mp3',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('TracksService', () => {
let service: TracksService;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [
TracksService,
{ provide: getRepositoryToken(TrackEntity), useValue: trackRepoMock },
{ provide: WaveformService, useValue: waveformServiceMock },
],
}).compile();

service = module.get(TracksService);
});

// ── createTrack ─────────────────────────────────────────────────────────────

describe('createTrack', () => {
it('persists the track and enqueues waveform generation', async () => {
const track = makeTrack();
trackRepoMock.create.mockReturnValue(track);
trackRepoMock.save.mockResolvedValue(track);
waveformServiceMock.enqueueForTrack.mockResolvedValue('job-1');

const result = await service.createTrack({
title: 'Test Track',
audioFilePath: '/uploads/test.mp3',
});

expect(trackRepoMock.save).toHaveBeenCalledWith(track);
expect(waveformServiceMock.enqueueForTrack).toHaveBeenCalledWith(
'track-uuid',
'/uploads/test.mp3',
);
expect(result).toEqual(track);
});

it('does NOT use setTimeout (no timer mocking needed)', async () => {
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
const track = makeTrack();
trackRepoMock.create.mockReturnValue(track);
trackRepoMock.save.mockResolvedValue(track);
waveformServiceMock.enqueueForTrack.mockResolvedValue('job-1');

await service.createTrack({ title: 'T', audioFilePath: '/a.mp3' });

// Durable queue – no in-process timers
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});
});

// ── findOne ─────────────────────────────────────────────────────────────────

describe('findOne', () => {
it('returns the track when found', async () => {
const track = makeTrack();
trackRepoMock.findOne.mockResolvedValue(track);

const result = await service.findOne('track-uuid');
expect(result).toEqual(track);
});

it('throws NotFoundException when track is missing', async () => {
trackRepoMock.findOne.mockResolvedValue(null);
await expect(service.findOne('ghost')).rejects.toThrow(NotFoundException);
});
});
});
54 changes: 54 additions & 0 deletions backend/src/mount-waveform/tracks.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { WaveformService } from '../waveform/waveform.service';
import { CreateTrackDto } from './dto/create-track.dto';
import { TrackEntity } from './track.entity';
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Import paths likely incorrect – same issues as spec file.

The import '../waveform/waveform.service' suggests a separate waveform/ directory, but other files in this PR place waveform code in mount-waveform/. Similarly, TrackEntity from './track.entity' caused a build failure in the spec file. Verify and correct these paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/mount-waveform/tracks.service.ts` around lines 9 - 11, The
imports for WaveformService, CreateTrackDto and TrackEntity are pointing to the
wrong module locations; update the import statements so WaveformService and any
waveform-related types are imported from the mount-waveform module where the
waveform code actually lives and ensure TrackEntity is imported from the module
that defines the entity (replace the incorrect '../waveform/waveform.service'
and './track.entity' imports with the correct module locations used elsewhere in
this PR so the spec/build can resolve WaveformService, CreateTrackDto and
TrackEntity).


/**
* TracksService owns the track lifecycle and delegates waveform generation
* to WaveformService – no direct queue interaction here.
*
* Key change: `createTrack` now calls `waveformService.enqueueForTrack`
* after persisting the track, replacing any fire-and-forget setTimeout that
* may have existed.
*/
@Injectable()
export class TracksService {
private readonly logger = new Logger(TracksService.name);

constructor(
@InjectRepository(TrackEntity)
private readonly trackRepo: Repository<TrackEntity>,

// Injected from WaveformModule (exported).
private readonly waveformService: WaveformService,
) {}

async createTrack(dto: CreateTrackDto): Promise<TrackEntity> {
const track = this.trackRepo.create(dto);
await this.trackRepo.save(track);

this.logger.log(`Track ${track.id} created – enqueuing waveform generation`);

// Durable – survives process restarts. No setTimeout.
await this.waveformService.enqueueForTrack(track.id, track.audioFilePath);

return track;
}
Comment on lines +33 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify TrackEntity has audioFilePath field
echo "=== Searching for TrackEntity definition ==="
ast-grep --pattern $'class TrackEntity {
  $$$
}'

echo "=== Searching for audioFilePath in track entities ==="
rg -n 'audioFilePath' --type ts

Repository: OlufunbiIK/tip-tune

Length of output: 2035


🏁 Script executed:

#!/bin/bash
# Find track entity and DTO files
echo "=== Finding track-related entity/DTO files ==="
fd -e ts -i track | grep -E "(entity|dto)" | head -20

echo "=== Searching for CreateTrackDto definition ==="
rg -n 'class CreateTrackDto|interface CreateTrackDto' -A 10 --type ts

echo "=== Searching for TrackEntity definition ==="
rg -n 'class TrackEntity|interface TrackEntity|export class TrackEntity' -A 15 --type ts

Repository: OlufunbiIK/tip-tune

Length of output: 1474


🏁 Script executed:

#!/bin/bash
# Read the actual entity and DTO files
echo "=== TrackEntity (backend/src/tracks/entities/track.entity.ts) ==="
cat backend/src/tracks/entities/track.entity.ts

echo ""
echo "=== CreateTrackDto (backend/src/tracks/dto/create-track.dto.ts) ==="
cat backend/src/tracks/dto/create-track.dto.ts

Repository: OlufunbiIK/tip-tune

Length of output: 4861


Fix field mismatch: audioFilePath does not exist on Track entity.

The Track entity defines audioUrl, not audioFilePath. Accessing track.audioFilePath on line 40 returns undefined, which is then passed to enqueueForTrack(), causing waveform processing to fail silently. Change track.audioFilePath to track.audioUrl.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/mount-waveform/tracks.service.ts` around lines 33 - 43, In
createTrack (tracks.service.ts) the code passes a non-existent field
track.audioFilePath to waveformService.enqueueForTrack causing undefined to be
enqueued; change the argument to use the Track entity's actual field
track.audioUrl (i.e., call enqueueForTrack(track.id, track.audioUrl)), keeping
the rest of the createTrack logic and logger the same so
waveformService.enqueueForTrack receives the correct URL value.


async findOne(id: string): Promise<TrackEntity> {
const track = await this.trackRepo.findOne({ where: { id } });
if (!track) throw new NotFoundException(`Track ${id} not found`);
return track;
}

async findAll(): Promise<TrackEntity[]> {
return this.trackRepo.find();
}
}
62 changes: 62 additions & 0 deletions backend/src/mount-waveform/waveform-generator.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { WaveformGeneratorService } from './waveform-generator.service';

describe('WaveformGeneratorService', () => {
let service: WaveformGeneratorService;
let tmpFile: string;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WaveformGeneratorService],
}).compile();

service = module.get(WaveformGeneratorService);
});

afterEach(() => {
if (tmpFile && fs.existsSync(tmpFile)) {
fs.unlinkSync(tmpFile);
}
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('generateFromFile', () => {
it('returns peaks array and durationSeconds for a valid file', async () => {
tmpFile = path.join(os.tmpdir(), `test-${Date.now()}.mp3`);
// Write 10 KB of fake audio data so peakCount > 100
fs.writeFileSync(tmpFile, Buffer.alloc(10 * 1024, 0xaa));

const result = await service.generateFromFile(tmpFile);

expect(result.peaks).toBeInstanceOf(Array);
expect(result.peaks.length).toBeGreaterThanOrEqual(100);
expect(result.durationSeconds).toBeGreaterThan(0);
result.peaks.forEach((p) => {
expect(p).toBeGreaterThanOrEqual(-1);
expect(p).toBeLessThanOrEqual(1);
});
});

it('produces deterministic peaks for the same file path', async () => {
tmpFile = path.join(os.tmpdir(), `det-test.mp3`);
fs.writeFileSync(tmpFile, Buffer.alloc(5 * 1024, 0xbb));

const a = await service.generateFromFile(tmpFile);
const b = await service.generateFromFile(tmpFile);

expect(a.peaks).toEqual(b.peaks);
});

it('throws when the file does not exist', async () => {
await expect(
service.generateFromFile('/nonexistent/path/audio.mp3'),
).rejects.toThrow('Audio file not found');
});
});
});
63 changes: 63 additions & 0 deletions backend/src/mount-waveform/waveform-generator.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';

export interface GeneratedWaveform {
peaks: number[];
/** Duration in seconds derived from the audio file. */
durationSeconds: number;
}

/**
* Responsible solely for reading an audio file and producing normalised
* peak-amplitude data. All retry / persistence logic lives elsewhere.
*
* Production swap-in: replace `generateFromFile` with a call to ffprobe /
* audiowaveform CLI or a cloud-based audio analysis service.
*/
@Injectable()
export class WaveformGeneratorService {
private readonly logger = new Logger(WaveformGeneratorService.name);

/**
* Generates waveform peaks from a local file path.
*
* @throws {Error} when the file cannot be read or parsed.
*/
async generateFromFile(filePath: string): Promise<GeneratedWaveform> {
this.logger.log(`Generating waveform for: ${filePath}`);

if (!fs.existsSync(filePath)) {
throw new Error(`Audio file not found: ${filePath}`);
}

// ------------------------------------------------------------------
// Real implementation would shell out to `audiowaveform` or `ffprobe`.
// The stub below produces deterministic fake data so the rest of the
// stack (queue, persistence, API) can be exercised in tests.
// ------------------------------------------------------------------
const stats = fs.statSync(filePath);
const peakCount = Math.max(100, Math.floor(stats.size / 1024));
const peaks = this.buildFakePeaks(peakCount, filePath);
const durationSeconds = peakCount * 0.1; // 100 ms per sample – rough stub

return { peaks, durationSeconds };
}

// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------

private buildFakePeaks(count: number, seed: string): number[] {
// Simple seeded PRNG so tests get deterministic output.
let s = seed.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
const rand = () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};

return Array.from({ length: count }, () =>
parseFloat((rand() * 2 - 1).toFixed(4)),
);
}
}
12 changes: 12 additions & 0 deletions backend/src/mount-waveform/waveform.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const WAVEFORM_QUEUE = 'waveform';

export const WAVEFORM_JOBS = {
GENERATE: 'generate',
} as const;

export const WAVEFORM_JOB_DEFAULTS = {
/** Max BullMQ attempts before moving to failed */
ATTEMPTS: 5,
/** Exponential back-off: 30 s, 60 s, 120 s, 240 s, 480 s */
BACKOFF_DELAY_MS: 30_000,
} as const;
81 changes: 81 additions & 0 deletions backend/src/mount-waveform/waveform.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { WaveformStatus } from './dto/waveform.dto';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

This spec cannot compile until the DTO module is wired up.

Backend CI is already failing with TS2307 on Line 3, so ./dto/waveform.dto is either missing from the PR or the relative path is wrong. Please add the DTO file or fix the import before merge.

🧰 Tools
🪛 GitHub Actions: Backend CI

[error] 3-3: TS2307: Cannot find module './dto/waveform.dto' or its corresponding type declarations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/mount-waveform/waveform.controller.spec.ts` at line 3, The test
import for WaveformStatus in waveform.controller.spec.ts fails because the DTO
module is missing or the relative path is incorrect; either add the missing DTO
file exporting WaveformStatus (e.g., export enum WaveformStatus { ... } or
export type/const as used) or correct the import path to the actual location of
the DTO, then ensure the exported symbol name matches the import
(WaveformStatus) so TypeScript can resolve './dto/waveform.dto' in the test.

import { WaveformController } from './waveform.controller';
import { WaveformService } from './waveform.service';

const waveformServiceMock = {
getStatus: jest.fn(),
regenerate: jest.fn(),
};

describe('WaveformController', () => {
let controller: WaveformController;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
controllers: [WaveformController],
providers: [{ provide: WaveformService, useValue: waveformServiceMock }],
}).compile();

controller = module.get(WaveformController);
});

it('should be defined', () => expect(controller).toBeDefined());

// ── GET /tracks/:trackId/waveform ─────────────────────────────────────────

describe('getStatus', () => {
it('delegates to waveformService.getStatus', async () => {
const dto = {
status: WaveformStatus.DONE,
peaks: [0.1],
attempts: 1,
updatedAt: new Date().toISOString(),
};
waveformServiceMock.getStatus.mockResolvedValue(dto);

const result = await controller.getStatus('track-uuid');

expect(waveformServiceMock.getStatus).toHaveBeenCalledWith('track-uuid');
expect(result).toEqual(dto);
});

it('propagates NotFoundException', async () => {
waveformServiceMock.getStatus.mockRejectedValue(
new NotFoundException('not found'),
);
await expect(controller.getStatus('ghost')).rejects.toThrow(
NotFoundException,
);
});
});

// ── POST /tracks/:trackId/waveform/regenerate ─────────────────────────────

describe('regenerate', () => {
it('returns queued result', async () => {
waveformServiceMock.regenerate.mockResolvedValue({
result: 'queued',
jobId: 'j-42',
});

const result = await controller.regenerate('track-uuid');

expect(waveformServiceMock.regenerate).toHaveBeenCalledWith('track-uuid');
expect(result.result).toBe('queued');
});

it('returns already_processing when job is in-flight', async () => {
waveformServiceMock.regenerate.mockResolvedValue({
result: 'already_processing',
jobId: 'in-flight',
});

const result = await controller.regenerate('track-uuid');
expect(result.result).toBe('already_processing');
});
});
});
Loading
Loading