import '@angular/compiler'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Injector, runInInjectionContext, signal } from '@angular/core'; import { Store } from '@ngrx/store'; import { of } from 'rxjs'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; describe('AttachmentPersistenceService', () => { let database: { isReady: ReturnType>; getAllAttachments: ReturnType; getMessageById: ReturnType; saveAttachment: ReturnType; deleteAttachmentsForMessage: ReturnType; }; let attachmentStorage: { resolveExistingPath: ReturnType; resolveCanonicalStoredPath: ReturnType; readFile: ReturnType; readFileChunk: ReturnType; getFileSize: ReturnType; getFileUrl: ReturnType; canReadFileChunks: ReturnType; providesInlineObjectUrl: ReturnType; }; beforeEach(() => { database = { isReady: signal(true), getAllAttachments: vi.fn(() => Promise.resolve([ { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 1_500_000, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png' } ])), getAttachmentsForMessage: vi.fn(() => Promise.resolve([])), getMessageById: vi.fn(() => Promise.resolve(null)), saveAttachment: vi.fn(() => Promise.resolve()), deleteAttachmentsForMessage: vi.fn(() => Promise.resolve()) }; attachmentStorage = { resolveExistingPath: vi.fn(() => Promise.resolve('/appdata/photo.png')), resolveCanonicalStoredPath: vi.fn(() => Promise.resolve(null)), readFile: vi.fn(() => Promise.resolve('QUJD')), readFileChunk: vi.fn(() => Promise.resolve('QUJD')), getFileSize: vi.fn(() => Promise.resolve(3)), getFileUrl: vi.fn(() => Promise.resolve(null)), canReadFileChunks: vi.fn(() => true), canCopyFiles: vi.fn(() => true), createWritableFile: vi.fn(async () => '/appdata/server/room/files/setup.exe'), copyFile: vi.fn(async () => true), providesInlineObjectUrl: vi.fn(() => false) }; }); function createService(): AttachmentPersistenceService { const injector = Injector.create({ providers: [ AttachmentPersistenceService, AttachmentRuntimeStore, { provide: DatabaseService, useValue: database }, { provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: Store, useValue: { select: () => of('room-1') } } ] }); return runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); } it('loads attachment metadata at startup without eagerly hydrating blobs from disk', async () => { const service = createService(); await service.initFromDatabase(); expect(database.getAllAttachments).toHaveBeenCalledTimes(1); expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); expect(attachmentStorage.getFileSize).not.toHaveBeenCalled(); }); it('hydrates blob URLs on demand for a single attachment', async () => { const injector = Injector.create({ providers: [ AttachmentPersistenceService, AttachmentRuntimeStore, { provide: DatabaseService, useValue: database }, { provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: Store, useValue: { select: () => of('room-1') } } ] }); const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); const runtimeStore = injector.get(AttachmentRuntimeStore); await service.initFromDatabase(); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: false }; const versionBefore = runtimeStore.updated(); await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); expect(attachment.available).toBe(true); expect(attachment.objectUrl).toMatch(/^blob:/); expect(runtimeStore.updated()).toBeGreaterThan(versionBefore); expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png'); expect(attachmentStorage.readFileChunk).toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled(); }); it('does not duplicate disk-hydrated bytes into the original-file cache', async () => { const injector = Injector.create({ providers: [ AttachmentPersistenceService, AttachmentRuntimeStore, { provide: DatabaseService, useValue: database }, { provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: Store, useValue: { select: () => of('room-1') } } ] }); const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); const runtimeStore = injector.get(AttachmentRuntimeStore); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: false }; await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); expect(attachment.objectUrl).toMatch(/^blob:/); expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined(); }); it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => { attachmentStorage.canReadFileChunks.mockReturnValue(false); const service = createService(); await service.initFromDatabase(); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: false }; await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); expect(attachment.available).toBe(true); expect(attachment.objectUrl).toMatch(/^blob:/); expect(attachmentStorage.readFile).toHaveBeenCalledWith('/appdata/photo.png'); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); }); it('uses a native webview URL without rebuilding a blob (capacitor store)', async () => { attachmentStorage.providesInlineObjectUrl.mockReturnValue(true); attachmentStorage.resolveExistingPath.mockResolvedValue('metoyou/server/room/video/clip.mp4'); attachmentStorage.getFileUrl.mockResolvedValue('capacitor://localhost/_capacitor_file_/clip.mp4'); const service = createService(); await service.initFromDatabase(); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'clip.mp4', size: 1_024, mime: 'video/mp4', isImage: false, savedPath: 'metoyou/server/room/video/clip.mp4', available: false }; await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); expect(attachment.available).toBe(true); expect(attachment.objectUrl).toBe('capacitor://localhost/_capacitor_file_/clip.mp4'); expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith('metoyou/server/room/video/clip.mp4'); expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); }); it('copies an external upload path into app data and hydrates generic files without loading a blob', async () => { attachmentStorage.resolveExistingPath .mockResolvedValueOnce(null) .mockResolvedValue('/appdata/server/room/files/setup.exe'); const service = createService(); const attachment = { id: 'att-setup', messageId: 'msg-1', filename: 'setup.exe', size: 628 * 1024 * 1024, mime: 'application/octet-stream', isImage: false, filePath: '/home/ludde/Downloads/setup.exe', available: false }; await expect(service.ensurePersistedUploadHost(attachment)).resolves.toBe(true); expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe'); expect(attachment.available).toBe(true); expect(attachment.objectUrl).toBeUndefined(); expect(attachmentStorage.copyFile).toHaveBeenCalledWith( '/home/ludde/Downloads/setup.exe', '/appdata/server/room/files/setup.exe' ); expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); expect(database.saveAttachment).toHaveBeenCalled(); }); it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => { const service = createService(); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: false }; await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true); expect(attachment.savedPath).toBe('/appdata/photo.png'); expect(attachment.objectUrl).toBeUndefined(); expect(attachment.available).toBe(false); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled(); }); it('revokes display blobs while keeping disk paths for later rehydration', () => { const service = createService(); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: true, objectUrl: 'blob:http://localhost/abc' }; const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true); expect(attachment.objectUrl).toBeUndefined(); expect(attachment.savedPath).toBe('/appdata/photo.png'); expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc'); revokeSpy.mockRestore(); }); it('releases the cached original file when revoking a disk-backed display blob', () => { const injector = Injector.create({ providers: [ AttachmentPersistenceService, AttachmentRuntimeStore, { provide: DatabaseService, useValue: database }, { provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: Store, useValue: { select: () => of('room-1') } } ] }); const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); const runtimeStore = injector.get(AttachmentRuntimeStore); const attachment = { id: 'att-1', messageId: 'msg-1', filename: 'photo.png', size: 3, mime: 'image/png', isImage: true, savedPath: '/appdata/photo.png', available: true, objectUrl: 'blob:http://localhost/abc' }; const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); runtimeStore.setOriginalFile('msg-1:att-1', new File(['abc'], 'photo.png', { type: 'image/png' })); expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true); expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined(); revokeSpy.mockRestore(); }); it('keeps the cached original file when the attachment is not persisted to disk yet', () => { const injector = Injector.create({ providers: [ AttachmentPersistenceService, AttachmentRuntimeStore, { provide: DatabaseService, useValue: database }, { provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: Store, useValue: { select: () => of('room-1') } } ] }); const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); const runtimeStore = injector.get(AttachmentRuntimeStore); const attachment = { id: 'att-2', messageId: 'msg-1', filename: 'clip.mp4', size: 3, mime: 'video/mp4', isImage: false, available: true, objectUrl: 'blob:http://localhost/def' }; runtimeStore.setOriginalFile('msg-1:att-2', new File(['abc'], 'clip.mp4', { type: 'video/mp4' })); expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(false); expect(runtimeStore.getOriginalFile('msg-1:att-2')).toBeDefined(); }); });