|
@@ -25,7 +25,7 @@
|
|
| 25 |
aria-label="Search"
|
| 26 |
/>
|
| 27 |
|
| 28 |
-
@for (b of
|
| 29 |
<app-book-card [book]="b" (like)="addLikedBook($event)" />
|
| 30 |
}
|
| 31 |
</div>
|
|
|
|
| 25 |
aria-label="Search"
|
| 26 |
/>
|
| 27 |
|
| 28 |
+
@for (b of books.value(); track b.isbn) {
|
| 29 |
<app-book-card [book]="b" (like)="addLikedBook($event)" />
|
| 30 |
}
|
| 31 |
</div>
|
|
@@ -1,23 +1,29 @@
|
|
|
|
|
| 1 |
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
| 2 |
-
import { provideRouter } from '@angular/router';
|
| 3 |
import { RouterTestingHarness } from '@angular/router/testing';
|
| 4 |
-
import {
|
| 5 |
|
| 6 |
-
import { BooksOverviewPage } from './books-overview-page';
|
| 7 |
-
import { booksPortalRoutes } from '../books-portal.routes';
|
| 8 |
import { Book } from '../../shared/book';
|
| 9 |
import { BookStore } from '../../shared/book-store';
|
|
|
|
|
|
|
| 10 |
|
| 11 |
describe('BooksOverviewPage', () => {
|
| 12 |
let component: BooksOverviewPage;
|
| 13 |
let fixture: ComponentFixture<BooksOverviewPage>;
|
|
|
|
| 14 |
|
|
|
|
| 15 |
const mockBooks: Partial<Book>[] = [
|
| 16 |
{ isbn: '1234', title: 'Tierisch gut kochen' },
|
| 17 |
{ isbn: '5678', title: 'Backen mit Affen' }
|
| 18 |
];
|
| 19 |
|
| 20 |
beforeEach(async () => {
|
|
|
|
|
|
|
|
|
|
| 21 |
await TestBed.configureTestingModule({
|
| 22 |
imports: [BooksOverviewPage],
|
| 23 |
providers: [
|
|
@@ -25,8 +31,9 @@ describe('BooksOverviewPage', () => {
|
|
| 25 |
{
|
| 26 |
provide: BookStore,
|
| 27 |
useFactory: () => ({
|
| 28 |
-
getAll: () => resource({
|
| 29 |
-
|
|
|
|
| 30 |
})
|
| 31 |
})
|
| 32 |
}
|
|
@@ -34,7 +41,9 @@ describe('BooksOverviewPage', () => {
|
|
| 34 |
})
|
| 35 |
.compileComponents();
|
| 36 |
|
| 37 |
-
fixture = TestBed.createComponent(BooksOverviewPage
|
|
|
|
|
|
|
| 38 |
component = fixture.componentInstance;
|
| 39 |
await fixture.whenStable();
|
| 40 |
});
|
|
@@ -74,41 +83,41 @@ describe('BooksOverviewPage', () => {
|
|
| 74 |
expect(bookCardEls[1].textContent).toContain('Backen mit Affen');
|
| 75 |
});
|
| 76 |
|
| 77 |
-
it('should
|
| 78 |
-
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
expect(
|
| 82 |
});
|
| 83 |
|
| 84 |
-
it('should
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
expect(books.length).toBe(1);
|
| 89 |
-
expect(books[0].title).toBe('Backen mit Affen');
|
| 90 |
});
|
| 91 |
|
| 92 |
-
it('should
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
const books = component['filteredBooks']();
|
| 96 |
-
expect(books.length).toBe(1);
|
| 97 |
-
expect(books[0].title).toBe('Backen mit Affen');
|
| 98 |
-
});
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
expect(books.length).toBe(0);
|
| 105 |
});
|
| 106 |
|
| 107 |
-
it('should
|
| 108 |
-
const
|
| 109 |
-
const component = await harness.navigateByUrl('/books', BooksOverviewPage);
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
});
|
| 114 |
});
|
|
|
|
| 1 |
+
import { inputBinding, resource, signal } from '@angular/core';
|
| 2 |
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
| 3 |
+
import { provideRouter, Router } from '@angular/router';
|
| 4 |
import { RouterTestingHarness } from '@angular/router/testing';
|
| 5 |
+
import { Mock } from 'vitest';
|
| 6 |
|
|
|
|
|
|
|
| 7 |
import { Book } from '../../shared/book';
|
| 8 |
import { BookStore } from '../../shared/book-store';
|
| 9 |
+
import { booksPortalRoutes } from '../books-portal.routes';
|
| 10 |
+
import { BooksOverviewPage } from './books-overview-page';
|
| 11 |
|
| 12 |
describe('BooksOverviewPage', () => {
|
| 13 |
let component: BooksOverviewPage;
|
| 14 |
let fixture: ComponentFixture<BooksOverviewPage>;
|
| 15 |
+
let getAllMock: Mock;
|
| 16 |
|
| 17 |
+
const searchSignal = signal<string | undefined>(undefined);
|
| 18 |
const mockBooks: Partial<Book>[] = [
|
| 19 |
{ isbn: '1234', title: 'Tierisch gut kochen' },
|
| 20 |
{ isbn: '5678', title: 'Backen mit Affen' }
|
| 21 |
];
|
| 22 |
|
| 23 |
beforeEach(async () => {
|
| 24 |
+
searchSignal.set(undefined);
|
| 25 |
+
getAllMock = vi.fn().mockResolvedValue(mockBooks);
|
| 26 |
+
|
| 27 |
await TestBed.configureTestingModule({
|
| 28 |
imports: [BooksOverviewPage],
|
| 29 |
providers: [
|
|
|
|
| 31 |
{
|
| 32 |
provide: BookStore,
|
| 33 |
useFactory: () => ({
|
| 34 |
+
getAll: (searchTerm: () => string) => resource({
|
| 35 |
+
params: searchTerm,
|
| 36 |
+
loader: getAllMock,
|
| 37 |
})
|
| 38 |
})
|
| 39 |
}
|
|
|
|
| 41 |
})
|
| 42 |
.compileComponents();
|
| 43 |
|
| 44 |
+
fixture = TestBed.createComponent(BooksOverviewPage, {
|
| 45 |
+
bindings: [inputBinding('search', searchSignal)]
|
| 46 |
+
});
|
| 47 |
component = fixture.componentInstance;
|
| 48 |
await fixture.whenStable();
|
| 49 |
});
|
|
|
|
| 83 |
expect(bookCardEls[1].textContent).toContain('Backen mit Affen');
|
| 84 |
});
|
| 85 |
|
| 86 |
+
it('should load the BooksOverviewPage for /books', async () => {
|
| 87 |
+
const harness = await RouterTestingHarness.create();
|
| 88 |
+
const component = await harness.navigateByUrl('/books', BooksOverviewPage);
|
| 89 |
|
| 90 |
+
expect(component).toBeTruthy();
|
| 91 |
+
expect(document.title).toBe('Books');
|
| 92 |
});
|
| 93 |
|
| 94 |
+
it('should ask service initially for books', async () => {
|
| 95 |
+
expect(getAllMock).toHaveBeenCalledExactlyOnceWith(
|
| 96 |
+
expect.objectContaining({ params: '' })
|
| 97 |
+
);
|
| 98 |
|
| 99 |
+
expect(component['searchTerm']()).toBe('');
|
|
|
|
|
|
|
| 100 |
});
|
| 101 |
|
| 102 |
+
it('should update searchTerm when query param changes', async () => {
|
| 103 |
+
searchSignal.set('Angular');
|
| 104 |
+
await fixture.whenStable();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
expect(getAllMock).toHaveBeenLastCalledWith(
|
| 107 |
+
expect.objectContaining({ params: 'Angular' })
|
| 108 |
+
);
|
| 109 |
|
| 110 |
+
expect(component['searchTerm']()).toBe('Angular');
|
|
|
|
| 111 |
});
|
| 112 |
|
| 113 |
+
it('should sync searchTerm to URL via Router', async () => {
|
| 114 |
+
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
|
|
|
|
| 115 |
|
| 116 |
+
component['searchTerm'].set('Angular');
|
| 117 |
+
await fixture.whenStable();
|
| 118 |
+
|
| 119 |
+
expect(navigateSpy).toHaveBeenCalledWith([], {
|
| 120 |
+
queryParams: { search: 'Angular' }
|
| 121 |
+
});
|
| 122 |
});
|
| 123 |
});
|
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import { Component,
|
|
|
|
| 2 |
|
| 3 |
import { Book } from '../../shared/book';
|
| 4 |
import { BookCard } from '../book-card/book-card';
|
|
@@ -12,24 +13,23 @@ import { BookStore } from '../../shared/book-store';
|
|
| 12 |
})
|
| 13 |
export class BooksOverviewPage {
|
| 14 |
#bookStore = inject(BookStore);
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
protected books = this.#bookStore.getAll();
|
| 19 |
protected likedBooks = signal<Book[]>([]);
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
const term = this.searchTerm().toLowerCase();
|
| 31 |
-
return this.books.value().filter((b) => b.title.toLowerCase().includes(term));
|
| 32 |
-
});
|
| 33 |
|
| 34 |
addLikedBook(newLikedBook: Book) {
|
| 35 |
const foundBook = this.likedBooks().find(
|
|
|
|
| 1 |
+
import { Component, effect, inject, input, linkedSignal, signal } from '@angular/core';
|
| 2 |
+
import { Router } from '@angular/router';
|
| 3 |
|
| 4 |
import { Book } from '../../shared/book';
|
| 5 |
import { BookCard } from '../book-card/book-card';
|
|
|
|
| 13 |
})
|
| 14 |
export class BooksOverviewPage {
|
| 15 |
#bookStore = inject(BookStore);
|
| 16 |
+
#router = inject(Router);
|
| 17 |
|
| 18 |
+
readonly search = input<string>();
|
| 19 |
+
protected searchTerm = linkedSignal(() => this.search() || '');
|
| 20 |
|
| 21 |
+
protected books = this.#bookStore.getAll(() => this.searchTerm());
|
| 22 |
protected likedBooks = signal<Book[]>([]);
|
| 23 |
|
| 24 |
+
constructor() {
|
| 25 |
+
effect(() => {
|
| 26 |
+
this.#router.navigate([], {
|
| 27 |
+
queryParams: {
|
| 28 |
+
search: this.searchTerm() || null
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
});
|
| 32 |
+
}
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
addLikedBook(newLikedBook: Book) {
|
| 35 |
const foundBook = this.likedBooks().find(
|
|
@@ -36,12 +36,12 @@ describe('BookStore', () => {
|
|
| 36 |
|
| 37 |
let booksResource!: HttpResourceRef<Book[]>;
|
| 38 |
runInInjectionContext(injector, () => {
|
| 39 |
-
booksResource = service.getAll();
|
| 40 |
});
|
| 41 |
|
| 42 |
TestBed.tick();
|
| 43 |
|
| 44 |
-
const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
|
| 45 |
expect(req.request.method).toBe('GET');
|
| 46 |
req.flush(mockBooks);
|
| 47 |
|
|
@@ -81,16 +81,28 @@ describe('BookStore', () => {
|
|
| 81 |
it('should handle server errors', async () => {
|
| 82 |
let booksResource!: HttpResourceRef<Book[]>;
|
| 83 |
runInInjectionContext(injector, () => {
|
| 84 |
-
booksResource = service.getAll();
|
| 85 |
});
|
| 86 |
|
| 87 |
TestBed.tick();
|
| 88 |
|
| 89 |
-
const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
|
| 90 |
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
|
| 91 |
|
| 92 |
await TestBed.inject(ApplicationRef).whenStable();
|
| 93 |
expect(booksResource.error()).toBeInstanceOf(HttpErrorResponse);
|
| 94 |
expect((booksResource.error() as HttpErrorResponse).status).toBe(500);
|
| 95 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
});
|
|
|
|
| 36 |
|
| 37 |
let booksResource!: HttpResourceRef<Book[]>;
|
| 38 |
runInInjectionContext(injector, () => {
|
| 39 |
+
booksResource = service.getAll(() => '');
|
| 40 |
});
|
| 41 |
|
| 42 |
TestBed.tick();
|
| 43 |
|
| 44 |
+
const req = httpTesting.expectOne('https://api1.angular-buch.com/books?filter=');
|
| 45 |
expect(req.request.method).toBe('GET');
|
| 46 |
req.flush(mockBooks);
|
| 47 |
|
|
|
|
| 81 |
it('should handle server errors', async () => {
|
| 82 |
let booksResource!: HttpResourceRef<Book[]>;
|
| 83 |
runInInjectionContext(injector, () => {
|
| 84 |
+
booksResource = service.getAll(() => '');
|
| 85 |
});
|
| 86 |
|
| 87 |
TestBed.tick();
|
| 88 |
|
| 89 |
+
const req = httpTesting.expectOne('https://api1.angular-buch.com/books?filter=');
|
| 90 |
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
|
| 91 |
|
| 92 |
await TestBed.inject(ApplicationRef).whenStable();
|
| 93 |
expect(booksResource.error()).toBeInstanceOf(HttpErrorResponse);
|
| 94 |
expect((booksResource.error() as HttpErrorResponse).status).toBe(500);
|
| 95 |
});
|
| 96 |
+
|
| 97 |
+
it('should include search filter in HTTP request', () => {
|
| 98 |
+
runInInjectionContext(injector, () => {
|
| 99 |
+
service.getAll(() => 'Angular');
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
TestBed.tick();
|
| 103 |
+
|
| 104 |
+
httpTesting
|
| 105 |
+
.expectOne(r => r.params.get('filter') === 'Angular')
|
| 106 |
+
.flush([]);
|
| 107 |
+
});
|
| 108 |
});
|
|
@@ -11,9 +11,12 @@ export class BookStore {
|
|
| 11 |
#http = inject(HttpClient);
|
| 12 |
#apiUrl = 'https://api1.angular-buch.com';
|
| 13 |
|
| 14 |
-
getAll(): HttpResourceRef<Book[]> {
|
| 15 |
return httpResource<Book[]>(
|
| 16 |
-
() =>
|
|
|
|
|
|
|
|
|
|
| 17 |
{ defaultValue: [] }
|
| 18 |
);
|
| 19 |
}
|
|
|
|
| 11 |
#http = inject(HttpClient);
|
| 12 |
#apiUrl = 'https://api1.angular-buch.com';
|
| 13 |
|
| 14 |
+
getAll(searchTerm: () => string): HttpResourceRef<Book[]> {
|
| 15 |
return httpResource<Book[]>(
|
| 16 |
+
() => ({
|
| 17 |
+
url: `${this.#apiUrl}/books`,
|
| 18 |
+
params: { filter: searchTerm() }
|
| 19 |
+
}),
|
| 20 |
{ defaultValue: [] }
|
| 21 |
);
|
| 22 |
}
|