Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Failing after 3s
Pipeline / API Lint (push) Failing after 2s
Pipeline / API Integration Tests (push) Failing after 0s
Pipeline / UI Tests (push) Successful in 1m28s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
- Remove unused NEVER import from detail.component.spec.ts - Replace `null as unknown as ImageRecord` with `null as unknown as typeof MOCK_IMAGE` to match the narrower inferred type (thumbnail_key: null) that setup() expects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
195 lines
8.1 KiB
TypeScript
195 lines
8.1 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 { ToastService } from '../services/toast.service';
|
||
import { routes } from '../app.routes';
|
||
|
||
const MOCK_IMAGE = {
|
||
id: 'img-1', short_id: 'AbCd1234', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||
size_bytes: 100, width: 10, height: 10, storage_key: 'AbCd1234',
|
||
thumbnail_key: null, file_url: '/api/v1/i/AbCd1234/file', thumbnail_url: null,
|
||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||
};
|
||
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
||
|
||
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('AbCd1234', ['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('AbCd1234', ['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('AbCd1234');
|
||
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', () => {
|
||
// Service returns null → fetchImage sets image=null, loading=false, markForCheck()
|
||
const { fixture } = setup('img-1', of(null as unknown as typeof MOCK_IMAGE));
|
||
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
|
||
});
|
||
|
||
it('tag error element uses danger styling class', () => {
|
||
const { fixture, component, imgSvc } = setup();
|
||
spyOn(imgSvc, 'updateTags').and.returnValue(throwError(() => ({ error: { detail: 'Invalid tag' } })));
|
||
component.addTag('bad#tag');
|
||
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);
|
||
});
|
||
|
||
// ---- Copy URL ----
|
||
|
||
it('Copy URL button is present when image is loaded', () => {
|
||
const { fixture } = setup();
|
||
expect((fixture.nativeElement as HTMLElement).querySelector('.copy-url-btn')).not.toBeNull();
|
||
});
|
||
|
||
it('copyUrl() calls writeText with file_url when it is already absolute', async () => {
|
||
const { component } = setup('img-1', of(MOCK_IMAGE_ABS));
|
||
const toast = TestBed.inject(ToastService);
|
||
spyOn(toast, 'show');
|
||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||
component.copyUrl();
|
||
await Promise.resolve();
|
||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_IMAGE_ABS.file_url);
|
||
});
|
||
|
||
it('copyUrl() prepends window.location.origin when file_url is relative', async () => {
|
||
const { component } = setup();
|
||
const toast = TestBed.inject(ToastService);
|
||
spyOn(toast, 'show');
|
||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||
component.copyUrl();
|
||
await Promise.resolve();
|
||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.origin + MOCK_IMAGE.file_url);
|
||
});
|
||
|
||
it('copyUrl() calls toast.show with success message on writeText resolve', async () => {
|
||
const { component } = setup();
|
||
const toast = TestBed.inject(ToastService);
|
||
spyOn(toast, 'show');
|
||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||
component.copyUrl();
|
||
await Promise.resolve();
|
||
expect(toast.show).toHaveBeenCalledWith('URL copied!');
|
||
});
|
||
|
||
it('copyUrl() calls toast.show with error message on writeText reject', async () => {
|
||
const { component } = setup();
|
||
const toast = TestBed.inject(ToastService);
|
||
spyOn(toast, 'show');
|
||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
|
||
component.copyUrl();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
expect(toast.show).toHaveBeenCalledWith('Failed to copy URL', 'error');
|
||
});
|
||
});
|