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
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true
}
},
"ghcr.io/devcontainers-extra/features/renovate-cli:2": {}
},
"forwardPorts": [
3000,
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/catalog-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Catalog Service Tests

on:
pull_request:
branches: [ main ]
paths:
- 'backend/catalog/**'
- '.github/workflows/catalog-tests.yml'
push:
branches: [ main ]
paths:
- 'backend/catalog/**'
- '.github/workflows/catalog-tests.yml'

jobs:
test:
runs-on: ubuntu-latest

defaults:
run:
working-directory: backend/catalog

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/catalog/package-lock.json

- name: Install dependencies
run: npm install

- name: Run tests
run: npm run test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ coverage/
.pgsql/

services/database/database.sqlite

# Renovate debug files
renovate_debug.txt
renovate_output.txt
jest_debug.txt
8 changes: 8 additions & 0 deletions backend/catalog/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testTimeout: 5000,
forceExit: true,
verbose: true,
bail: true
};
105 changes: 105 additions & 0 deletions backend/catalog/src/__tests__/deprecated-matchers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
describe('Jest v29 Deprecated Matchers Demo', () => {
describe('Mock function matchers that will break in Jest v30', () => {
it('uses toBeCalled instead of toHaveBeenCalled', () => {
const mockFn = jest.fn();
mockFn('test');

expect(mockFn).toBeCalled();
});

it('uses toBeCalledTimes instead of toHaveBeenCalledTimes', () => {
const mockFn = jest.fn();
mockFn('first');
mockFn('second');

expect(mockFn).toBeCalledTimes(2);
});

it('uses toBeCalledWith instead of toHaveBeenCalledWith', () => {
const mockFn = jest.fn();
mockFn('test-arg');

expect(mockFn).toBeCalledWith('test-arg');
});

it('uses lastCalledWith instead of toHaveBeenLastCalledWith', () => {
const mockFn = jest.fn();
mockFn('first');
mockFn('last');

expect(mockFn).lastCalledWith('last');
});

it('uses nthCalledWith instead of toHaveBeenNthCalledWith', () => {
const mockFn = jest.fn();
mockFn('first');
mockFn('second');

expect(mockFn).nthCalledWith(1, 'first');
expect(mockFn).nthCalledWith(2, 'second');
});
});

describe('Return value matchers that will break in Jest v30', () => {
it('uses toReturn instead of toHaveReturned', () => {
const mockFn = jest.fn().mockReturnValue('result');
mockFn();

expect(mockFn).toReturn();
});

it('uses toReturnTimes instead of toHaveReturnedTimes', () => {
const mockFn = jest.fn().mockReturnValue('result');
mockFn();
mockFn();

expect(mockFn).toReturnTimes(2);
});

it('uses toReturnWith instead of toHaveReturnedWith', () => {
const mockFn = jest.fn().mockReturnValue('specific-result');
mockFn();

expect(mockFn).toReturnWith('specific-result');
});

it('uses lastReturnedWith instead of toHaveLastReturnedWith', () => {
const mockFn = jest.fn();
mockFn.mockReturnValueOnce('first');
mockFn.mockReturnValueOnce('last');
mockFn();
mockFn();

expect(mockFn).lastReturnedWith('last');
});

it('uses nthReturnedWith instead of toHaveNthReturnedWith', () => {
const mockFn = jest.fn();
mockFn.mockReturnValueOnce('first');
mockFn.mockReturnValueOnce('second');
mockFn();
mockFn();

expect(mockFn).nthReturnedWith(1, 'first');
expect(mockFn).nthReturnedWith(2, 'second');
});
});

describe('Error matchers that will break in Jest v30', () => {
it('uses toThrowError instead of toThrow', () => {
const errorFn = () => {
throw new Error('Test error');
};

expect(errorFn).toThrowError('Test error');
});

it('uses toThrowError with no message instead of toThrow', () => {
const errorFn = () => {
throw new Error('Any error');
};

expect(errorFn).toThrowError();
});
});
});
44 changes: 44 additions & 0 deletions backend/catalog/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { formatMovieTitle, calculateAverageRating } from '../utils/movieUtils';

describe('Movie Utilities', () => {
describe('formatMovieTitle', () => {
it('should format title with proper capitalization', () => {
expect(formatMovieTitle('the dark knight')).toBe('The Dark Knight');
expect(formatMovieTitle('PULP FICTION')).toBe('Pulp Fiction');
expect(formatMovieTitle('fight club')).toBe('Fight Club');
});

it('should handle empty strings', () => {
expect(formatMovieTitle('')).toBe('');
});

it('should handle single words', () => {
expect(formatMovieTitle('matrix')).toBe('Matrix');
expect(formatMovieTitle('MATRIX')).toBe('Matrix');
});
});

describe('calculateAverageRating', () => {
it('should calculate correct average rating', () => {
const movies = [
{ title: 'Movie 1', description: 'Desc 1', release_year: 2000, rating: 8.0, image_url: 'url1' },
{ title: 'Movie 2', description: 'Desc 2', release_year: 2001, rating: 9.0, image_url: 'url2' },
{ title: 'Movie 3', description: 'Desc 3', release_year: 2002, rating: 7.0, image_url: 'url3' }
];

expect(calculateAverageRating(movies)).toBe(8.0);
});

it('should return 0 for empty array', () => {
expect(calculateAverageRating([])).toBe(0);
});

it('should handle single movie', () => {
const movies = [
{ title: 'Solo Movie', description: 'Desc', release_year: 2000, rating: 7.5, image_url: 'url' }
];

expect(calculateAverageRating(movies)).toBe(7.5);
});
});
});
45 changes: 45 additions & 0 deletions backend/catalog/src/services/catalogService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Pool } from 'pg';
import { Movie } from '../utils/movieUtils';

export class CatalogService {
private pool: Pool;

constructor(pool: Pool) {
this.pool = pool;
}

async getAllMovies(): Promise<Movie[]> {
const result = await this.pool.query('SELECT * FROM movies ORDER BY rating DESC');
return result.rows;
}

async getMovieById(id: number): Promise<Movie | null> {
const result = await this.pool.query('SELECT * FROM movies WHERE id = $1', [id]);
return result.rows[0] || null;
}

async searchMovies(query: string): Promise<Movie[]> {
const searchQuery = `%${query.toLowerCase()}%`;
const result = await this.pool.query(
'SELECT * FROM movies WHERE LOWER(title) LIKE $1 OR LOWER(description) LIKE $1',
[searchQuery]
);
return result.rows;
}

async getTopRatedMovies(limit: number = 10): Promise<Movie[]> {
const result = await this.pool.query(
'SELECT * FROM movies ORDER BY rating DESC LIMIT $1',
[limit]
);
return result.rows;
}

async getMoviesByYear(year: number): Promise<Movie[]> {
const result = await this.pool.query(
'SELECT * FROM movies WHERE release_year = $1 ORDER BY rating DESC',
[year]
);
return result.rows;
}
}
67 changes: 67 additions & 0 deletions backend/catalog/src/utils/dataProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export interface ProcessingResult<T> {
data: T[];
processed: number;
errors: string[];
}

export function processMovieData<T>(
items: T[],
processor: (item: T) => T | null
): ProcessingResult<T> {
const result: ProcessingResult<T> = {
data: [],
processed: 0,
errors: []
};

for (const item of items) {
try {
const processed = processor(item);
if (processed !== null) {
result.data.push(processed);
result.processed++;
}
} catch (error) {
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
}
}

return result;
}

export function batchProcess<T, R>(
items: T[],
batchSize: number,
processor: (batch: T[]) => Promise<R[]>
): Promise<R[]> {
const batches: T[][] = [];

for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}

return Promise.all(batches.map(processor)).then(results =>
results.flat()
);
}

export function sanitizeInput(input: string): string {
return input
.trim()
.replace(/<[^>]*>/g, '')
.replace(/script/gi, '')
.substring(0, 1000);
}

export function parseRating(rating: string | number): number {
if (typeof rating === 'number') {
return Math.max(0, Math.min(10, rating));
}

const parsed = parseFloat(rating);
if (isNaN(parsed)) {
return 0;
}

return Math.max(0, Math.min(10, parsed));
}
66 changes: 66 additions & 0 deletions backend/catalog/src/utils/movieUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export interface Movie {
id?: number;
title: string;
description: string;
release_year: number;
rating: number;
image_url: string;
}

export function validateMovie(movie: Partial<Movie>): string[] {
const errors: string[] = [];

if (!movie.title || movie.title.trim().length === 0) {
errors.push('Title is required');
}

if (!movie.description || movie.description.trim().length === 0) {
errors.push('Description is required');
}

if (!movie.release_year || movie.release_year < 1900 || movie.release_year > new Date().getFullYear()) {
errors.push('Release year must be between 1900 and current year');
}

if (!movie.rating || movie.rating < 0 || movie.rating > 10) {
errors.push('Rating must be between 0 and 10');
}

if (!movie.image_url || !isValidUrl(movie.image_url)) {
errors.push('Valid image URL is required');
}

return errors;
}

export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}

export function formatMovieTitle(title: string): string {
return title
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}

export function calculateAverageRating(movies: Movie[]): number {
if (movies.length === 0) return 0;

const sum = movies.reduce((acc, movie) => acc + movie.rating, 0);
return Math.round((sum / movies.length) * 10) / 10;
}

export function filterMoviesByDecade(movies: Movie[], decade: number): Movie[] {
const startYear = decade;
const endYear = decade + 9;

return movies.filter(movie =>
movie.release_year >= startYear && movie.release_year <= endYear
);
}
Loading