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>
156 lines
4.5 KiB
TypeScript
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,/);
|
|
});
|
|
});
|
|
});
|