Files
Toju/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts
T

358 lines
13 KiB
TypeScript

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<typeof signal<boolean>>;
getAllAttachments: ReturnType<typeof vi.fn>;
getMessageById: ReturnType<typeof vi.fn>;
saveAttachment: ReturnType<typeof vi.fn>;
deleteAttachmentsForMessage: ReturnType<typeof vi.fn>;
};
let attachmentStorage: {
resolveExistingPath: ReturnType<typeof vi.fn>;
resolveCanonicalStoredPath: ReturnType<typeof vi.fn>;
readFile: ReturnType<typeof vi.fn>;
readFileChunk: ReturnType<typeof vi.fn>;
getFileSize: ReturnType<typeof vi.fn>;
getFileUrl: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>;
providesInlineObjectUrl: ReturnType<typeof vi.fn>;
};
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();
});
});