kammer/src/lib/data/photos.test.ts
Christopher Mühl 91c7476d37
feat: PWA photo capture with IndexedDB storage
Add full photo capture and management functionality using standard Web APIs:

- Photo capture via getUserMedia (camera) or file upload
- Automatic thumbnail generation (max 200px width)
- IndexedDB storage for photos with Blob support
- PhotoCapture component with camera preview and capture controls
- PhotoGallery component with grid view and fullscreen modal
- Integration into item detail page
- 9 new unit tests (all passing)

PWA-friendly implementation:
- No native dependencies required
- Works in mobile browsers
- Falls back to file upload if camera unavailable
- Stores photos locally in IndexedDB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 23:44:46 +01:00

156 lines
4.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createPhoto,
getPhotosByItemId,
getPhotoById,
deletePhoto,
deletePhotosByItemId,
blobToDataURL,
} from './photos';
import { getDB, resetDBPromise } from './db';
// Mock canvas for thumbnail generation
beforeEach(() => {
// Mock canvas context
const mockContext = {
drawImage: vi.fn(),
};
// Mock HTMLCanvasElement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HTMLCanvasElement.prototype.getContext = vi.fn(() => mockContext) as any;
HTMLCanvasElement.prototype.toBlob = vi.fn(function (callback) {
callback?.(new Blob(['thumbnail'], { type: 'image/jpeg' }));
});
// Mock HTMLImageElement
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
width = 1920;
height = 1080;
constructor() {
setTimeout(() => {
this.onload?.();
}, 0);
}
} as unknown as typeof Image;
// Mock URL.createObjectURL
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
});
afterEach(async () => {
// Clean up database between tests
const db = await getDB();
await db.clear('photos');
await resetDBPromise();
});
describe('photos', () => {
describe('createPhoto', () => {
it('creates a photo with thumbnail', async () => {
const blob = new Blob(['test image'], { type: 'image/jpeg' });
const photo = await createPhoto('item_abc', blob);
expect(photo.id).toMatch(/^photo_/);
expect(photo.itemId).toBe('item_abc');
expect(photo.blob).toBe(blob);
expect(photo.thumbnail).toBeInstanceOf(Blob);
expect(photo.createdAt).toBeTruthy();
// Verify it was saved to DB
const db = await getDB();
const saved = await db.get('photos', photo.id);
expect(saved?.id).toBe(photo.id);
expect(saved?.itemId).toBe(photo.itemId);
expect(saved?.createdAt).toBe(photo.createdAt);
});
it('generates unique IDs', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const photo1 = await createPhoto('item_abc', blob);
const photo2 = await createPhoto('item_abc', blob);
expect(photo1.id).not.toBe(photo2.id);
});
});
describe('getPhotosByItemId', () => {
it('returns all photos for an item', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const photo1 = await createPhoto('item_abc', blob);
const photo2 = await createPhoto('item_abc', blob);
await createPhoto('item_xyz', blob); // different item
const photos = await getPhotosByItemId('item_abc');
expect(photos).toHaveLength(2);
expect(photos.map((p) => p.id).sort()).toEqual([photo1.id, photo2.id].sort());
});
it('returns empty array if no photos', async () => {
const photos = await getPhotosByItemId('nonexistent');
expect(photos).toEqual([]);
});
});
describe('getPhotoById', () => {
it('returns a photo by ID', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const photo = await createPhoto('item_abc', blob);
const retrieved = await getPhotoById(photo.id);
expect(retrieved?.id).toBe(photo.id);
expect(retrieved?.itemId).toBe(photo.itemId);
});
it('returns undefined if not found', async () => {
const photo = await getPhotoById('nonexistent');
expect(photo).toBeUndefined();
});
});
describe('deletePhoto', () => {
it('deletes a photo', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const photo = await createPhoto('item_abc', blob);
await deletePhoto(photo.id);
const retrieved = await getPhotoById(photo.id);
expect(retrieved).toBeUndefined();
});
});
describe('deletePhotosByItemId', () => {
it('deletes all photos for an item', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
await createPhoto('item_abc', blob);
await createPhoto('item_abc', blob);
const photo3 = await createPhoto('item_xyz', blob); // different item
await deletePhotosByItemId('item_abc');
const photos = await getPhotosByItemId('item_abc');
expect(photos).toEqual([]);
// Other item's photos should remain
const otherPhotos = await getPhotosByItemId('item_xyz');
expect(otherPhotos).toHaveLength(1);
expect(otherPhotos[0].id).toBe(photo3.id);
});
});
describe('blobToDataURL', () => {
it('converts a blob to data URL', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const dataUrl = await blobToDataURL(blob);
expect(dataUrl).toMatch(/^data:image\/jpeg;base64,/);
});
});
});