Initial commit
This commit is contained in:
7
packages/shop/data/README.md
Normal file
7
packages/shop/data/README.md
Normal 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.
|
||||
34
packages/shop/data/eslint.config.mjs
Normal file
34
packages/shop/data/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
9
packages/shop/data/project.json
Normal file
9
packages/shop/data/project.json
Normal 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": {}
|
||||
}
|
||||
1
packages/shop/data/src/index.ts
Normal file
1
packages/shop/data/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/services/products.service';
|
||||
278
packages/shop/data/src/lib/services/products.service.spec.ts
Normal file
278
packages/shop/data/src/lib/services/products.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/shop/data/src/lib/services/products.service.ts
Normal file
139
packages/shop/data/src/lib/services/products.service.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
13
packages/shop/data/src/test-setup.ts
Normal file
13
packages/shop/data/src/test-setup.ts
Normal 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()
|
||||
);
|
||||
31
packages/shop/data/tsconfig.json
Normal file
31
packages/shop/data/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
packages/shop/data/tsconfig.lib.json
Normal file
27
packages/shop/data/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/shop/data/tsconfig.spec.json
Normal file
30
packages/shop/data/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
28
packages/shop/data/vite.config.mts
Normal file
28
packages/shop/data/vite.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user