Initial commit

This commit is contained in:
2026-07-27 18:01:07 +02:00
commit 0758b00d54
192 changed files with 45091 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# data
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test data` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,9 @@
{
"name": "data",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/shop/data/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shop", "type:data"],
"targets": {}
}

View File

@@ -0,0 +1 @@
export * from './lib/services/products.service';

View File

@@ -0,0 +1,278 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ProductsService } from './products.service';
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
describe('ProductsService', () => {
let service: ProductsService;
let httpMock: HttpTestingController;
const apiUrl = 'http://localhost:3333/api';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
ProductsService
],
});
service = TestBed.inject(ProductsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getProducts', () => {
const mockProductsResponse: ApiResponse<PaginatedResponse<Product>> = {
success: true,
data: {
items: [
{
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 100,
imageUrl: 'image1.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 10,
},
{
id: '2',
name: 'Product 2',
description: 'Description 2',
price: 200,
imageUrl: 'image2.jpg',
category: 'Clothing',
inStock: false,
rating: 3.5,
reviewCount: 5,
},
],
total: 2,
page: 1,
pageSize: 12,
totalPages: 1,
},
};
it('should return products with default pagination', () => {
service.getProducts().subscribe((response) => {
expect(response.items.length).toBe(2);
expect(response.total).toBe(2);
expect(response.page).toBe(1);
expect(service.loading()).toBeFalsy();
expect(service.error()).toBeNull();
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
expect(req.request.method).toBe('GET');
req.flush(mockProductsResponse);
});
it('should apply filters when provided', () => {
const filter: ProductFilter = {
category: 'Electronics',
minPrice: 50,
maxPrice: 150,
inStock: true,
searchTerm: 'test',
};
service.getProducts(filter, 2, 20).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(
`${apiUrl}/products?page=2&pageSize=20&category=Electronics&minPrice=50&maxPrice=150&inStock=true&searchTerm=test`
);
expect(req.request.method).toBe('GET');
req.flush(mockProductsResponse);
});
it('should handle error response', () => {
const errorResponse: ApiResponse<PaginatedResponse<Product>> = {
success: false,
error: 'Server error',
data: undefined as unknown
};
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProducts().subscribe((response) => {
expect(response.items).toEqual([]);
expect(response.total).toBe(0);
expect(service.error()).toContain('Server error');
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.flush(errorResponse);
consoleErrorSpy.mockRestore();
});
it('should handle network error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProducts().subscribe((response) => {
expect(response.items).toEqual([]);
expect(response.total).toBe(0);
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getProductById', () => {
const mockProduct: Product = {
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 100,
imageUrl: 'image1.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 10,
};
it('should return a product by id', () => {
const mockResponse: ApiResponse<Product> = {
success: true,
data: mockProduct,
};
service.getProductById('1').subscribe((product) => {
expect(product).toEqual(mockProduct);
expect(service.loading()).toBeFalsy();
expect(service.error()).toBeNull();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return null on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProductById('1').subscribe((product) => {
expect(product).toBeNull();
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getCategories', () => {
it('should return categories list', () => {
const mockCategories = ['Electronics', 'Clothing', 'Books'];
const mockResponse: ApiResponse<string[]> = {
success: true,
data: mockCategories,
};
service.getCategories().subscribe((categories) => {
expect(categories).toEqual(mockCategories);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return empty array on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getCategories().subscribe((categories) => {
expect(categories).toEqual([]);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getPriceRange', () => {
it('should return price range', () => {
const mockPriceRange = { min: 10, max: 500 };
const mockResponse: ApiResponse<{ min: number; max: number }> = {
success: true,
data: mockPriceRange,
};
service.getPriceRange().subscribe((range) => {
expect(range).toEqual(mockPriceRange);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return default range on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getPriceRange().subscribe((range) => {
expect(range).toEqual({ min: 0, max: 1000 });
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('loading and error signals', () => {
it('should set loading to true when fetching products', () => {
expect(service.loading()).toBeFalsy();
service.getProducts().subscribe();
expect(service.loading()).toBeTruthy();
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.flush({ success: true, data: { items: [], total: 0, page: 1, pageSize: 12, totalPages: 0 } });
expect(service.loading()).toBeFalsy();
});
it('should set error message on failure', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(service.error()).toBeNull();
service.getProductById('1').subscribe(() => {
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,139 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, catchError, of } from 'rxjs';
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
@Injectable({
providedIn: 'root',
})
export class ProductsService {
private readonly http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3333/api';
// Signals for state management
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
getProducts(
filter?: ProductFilter,
page = 1,
pageSize = 12
): Observable<PaginatedResponse<Product>> {
this.loadingSignal.set(true);
this.errorSignal.set(null);
let params = new HttpParams()
.set('page', page.toString())
.set('pageSize', pageSize.toString());
if (filter) {
if (filter.category) {
params = params.set('category', filter.category);
}
if (filter.minPrice !== undefined) {
params = params.set('minPrice', filter.minPrice.toString());
}
if (filter.maxPrice !== undefined) {
params = params.set('maxPrice', filter.maxPrice.toString());
}
if (filter.inStock !== undefined) {
params = params.set('inStock', filter.inStock.toString());
}
if (filter.searchTerm) {
params = params.set('searchTerm', filter.searchTerm);
}
}
return this.http
.get<ApiResponse<PaginatedResponse<Product>>>(`${this.apiUrl}/products`, {
params,
})
.pipe(
map((response) => {
this.loadingSignal.set(false);
if (!response.success) {
throw new Error(response.error || 'Failed to load products');
}
return response.data;
}),
catchError((error) => {
this.loadingSignal.set(false);
this.errorSignal.set(
error.message || 'An error occurred while loading products'
);
console.error('Error loading products:', error);
return of({
items: [],
total: 0,
page: 1,
pageSize: 12,
totalPages: 0,
});
})
);
}
getProductById(id: string): Observable<Product | null> {
this.loadingSignal.set(true);
this.errorSignal.set(null);
return this.http
.get<ApiResponse<Product>>(`${this.apiUrl}/products/${id}`)
.pipe(
map((response) => {
this.loadingSignal.set(false);
if (!response.success) {
throw new Error(response.error || 'Failed to load product');
}
return response.data;
}),
catchError((error) => {
this.loadingSignal.set(false);
this.errorSignal.set(
error.message || 'An error occurred while loading the product'
);
console.error('Error loading product:', error);
return of(null);
})
);
}
getCategories(): Observable<string[]> {
return this.http
.get<ApiResponse<string[]>>(`${this.apiUrl}/products-metadata/categories`)
.pipe(
map((response) => {
if (!response.success) {
throw new Error(response.error || 'Failed to load categories');
}
return response.data;
}),
catchError((error) => {
console.error('Error loading categories:', error);
return of([]);
})
);
}
getPriceRange(): Observable<{ min: number; max: number }> {
return this.http
.get<ApiResponse<{ min: number; max: number }>>(
`${this.apiUrl}/products-metadata/price-range`
)
.pipe(
map((response) => {
if (!response.success) {
throw new Error(response.error || 'Failed to load price range');
}
return response.data;
}),
catchError((error) => {
console.error('Error loading price range:', error);
return of({ min: 0, max: 1000 });
})
);
}
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);

View File

@@ -0,0 +1,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve",
"lib": ["dom", "es2022"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,30 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"isolatedModules": true
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import tsconfigPaths from 'vite-tsconfig-paths';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/packages/shop/data',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'data',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/packages/shop/data',
provider: 'v8' as const,
},
},
}));