- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
84 lines
3.0 KiB
TypeScript
84 lines
3.0 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 } 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,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
tags: ['cat', 'funny'],
|
||
};
|
||
|
||
describe('DetailComponent', () => {
|
||
function setup(imageId = 'img-1') {
|
||
TestBed.configureTestingModule({
|
||
imports: [DetailComponent],
|
||
providers: [
|
||
provideHttpClient(),
|
||
provideHttpClientTesting(),
|
||
provideRouter(routes),
|
||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(DetailComponent);
|
||
const component = fixture.componentInstance;
|
||
const imgSvc = TestBed.inject(ImageService);
|
||
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
|
||
fixture.detectChanges();
|
||
return { fixture, component, imgSvc };
|
||
}
|
||
|
||
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(['/']);
|
||
});
|
||
});
|