API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
5.9 KiB
TypeScript
147 lines
5.9 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
||
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
||
import { provideHttpClient } from '@angular/common/http';
|
||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||
import { of, throwError, Subject } from 'rxjs';
|
||
import { DetailComponent } from './detail.component';
|
||
import { ImageService } from '../services/image.service';
|
||
import { routes } from '../app.routes';
|
||
|
||
const MOCK_IMAGE = {
|
||
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
|
||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||
};
|
||
|
||
describe('DetailComponent', () => {
|
||
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||
TestBed.configureTestingModule({
|
||
imports: [DetailComponent],
|
||
providers: [
|
||
provideHttpClient(),
|
||
provideHttpClientTesting(),
|
||
provideRouter(routes),
|
||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(DetailComponent);
|
||
const imgSvc = TestBed.inject(ImageService);
|
||
spyOn(imgSvc, 'get').and.returnValue(imageResponse);
|
||
fixture.detectChanges();
|
||
return { fixture, component: fixture.componentInstance, imgSvc };
|
||
}
|
||
|
||
// Existing tests preserved
|
||
it('should call PATCH with removed tag absent when chip × is clicked', () => {
|
||
const { component, imgSvc } = setup();
|
||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||
component.removeTag('cat');
|
||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
|
||
});
|
||
|
||
it('should call PATCH with new tag included on addTag', () => {
|
||
const { component, imgSvc } = setup();
|
||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
|
||
component.addTag('new');
|
||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
|
||
});
|
||
|
||
it('should call DELETE and navigate to library on confirm delete', () => {
|
||
const { component, imgSvc } = setup();
|
||
const router = TestBed.inject(Router);
|
||
spyOn(router, 'navigate');
|
||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||
component.confirmDelete();
|
||
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
|
||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||
});
|
||
|
||
it('should NOT call DELETE when cancel is clicked', () => {
|
||
const { component, imgSvc } = setup();
|
||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||
component.showDeleteDialog = true;
|
||
component.cancelDelete();
|
||
expect(imgSvc.delete).not.toHaveBeenCalled();
|
||
expect(component.showDeleteDialog).toBeFalse();
|
||
});
|
||
|
||
it('back button should navigate to library', () => {
|
||
const { component } = setup();
|
||
const router = TestBed.inject(Router);
|
||
spyOn(router, 'navigate');
|
||
component.goBack();
|
||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||
});
|
||
|
||
// New polish tests
|
||
|
||
it('skeleton is visible while loading is true', () => {
|
||
TestBed.configureTestingModule({
|
||
imports: [DetailComponent],
|
||
providers: [
|
||
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(DetailComponent);
|
||
const imgSvc = TestBed.inject(ImageService);
|
||
// Don't emit — keep loading state
|
||
spyOn(imgSvc, 'get').and.returnValue(new Subject());
|
||
fixture.componentInstance.loading = true;
|
||
fixture.detectChanges();
|
||
expect((fixture.nativeElement as HTMLElement).querySelector('.image-skeleton')).not.toBeNull();
|
||
});
|
||
|
||
it('error card shown when error is true and loading is false', () => {
|
||
const fixture = (() => {
|
||
TestBed.configureTestingModule({
|
||
imports: [DetailComponent],
|
||
providers: [
|
||
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||
],
|
||
}).compileComponents();
|
||
return TestBed.createComponent(DetailComponent);
|
||
})();
|
||
const imgSvc = TestBed.inject(ImageService);
|
||
spyOn(imgSvc, 'get').and.returnValue(throwError(() => ({ status: 500 })));
|
||
fixture.detectChanges();
|
||
expect((fixture.nativeElement as HTMLElement).querySelector('.fetch-error-card')).not.toBeNull();
|
||
});
|
||
|
||
it('not-found card shown when image is null, loading is false, error is false', () => {
|
||
const { fixture, component } = setup('img-1', of(MOCK_IMAGE));
|
||
component.image = null;
|
||
component.loading = false;
|
||
component.error = false;
|
||
fixture.detectChanges();
|
||
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
|
||
});
|
||
|
||
it('tag error element uses danger styling class', () => {
|
||
const { fixture, component } = setup();
|
||
component.tagError = 'Invalid tag: special characters not allowed';
|
||
fixture.detectChanges();
|
||
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
|
||
expect(errEl).not.toBeNull();
|
||
});
|
||
|
||
it('onImgError sets src to placeholder SVG', () => {
|
||
const { fixture } = setup();
|
||
const imgEl = document.createElement('img');
|
||
imgEl.src = 'http://example.com/image.jpg';
|
||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||
expect(imgEl.src).toContain('data:image/svg+xml');
|
||
});
|
||
|
||
it('onImgError does not recurse when src already is a data URI', () => {
|
||
const { fixture } = setup();
|
||
const imgEl = document.createElement('img');
|
||
imgEl.src = 'data:image/svg+xml,placeholder';
|
||
const before = imgEl.src;
|
||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||
expect(imgEl.src).toBe(before);
|
||
});
|
||
});
|