358 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|