Files
reactbin/ui/src/app/detail/detail.component.spec.ts
agatha 61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00

197 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', () => {
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);
});
// ---- 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');
});
});