merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-13 09:21:09 +00:00
271 changed files with 20486 additions and 3940 deletions
+209 -22
View File
@@ -1,43 +1,61 @@
import { AssetFace, AssetFile, Exif } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
export const previewFile: AssetFile = {
id: 'file-1',
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
};
export const previewFile = factory.assetFile({ type: AssetFileType.Preview });
const thumbnailFile: AssetFile = {
id: 'file-2',
const thumbnailFile = factory.assetFile({
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/webp/path.ext',
};
});
const fullsizeFile: AssetFile = {
id: 'file-3',
const fullsizeFile = factory.assetFile({
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path.webp',
};
});
const sidecarFileWithExt: AssetFile = {
id: 'sidecar-with-ext',
const sidecarFileWithExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
};
});
const sidecarFileWithoutExt: AssetFile = {
id: 'sidecar-without-ext',
const sidecarFileWithoutExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
};
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited,
path: '/uploads/user-id/preview/path_edited.jpg',
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited,
path: '/uploads/user-id/fullsize/path_edited.jpg',
});
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles: AssetFile[] = [
fullsizeFile,
previewFile,
thumbnailFile,
editedFullsizeFile,
editedPreviewFile,
editedThumbnailFile,
];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return {
id: stackId,
@@ -104,6 +122,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
noWebpPath: Object.freeze({
@@ -142,6 +163,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
noThumbhash: Object.freeze({
@@ -177,6 +201,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
primaryImage: Object.freeze({
@@ -222,6 +249,9 @@ export const assetStub = {
updateId: '42',
libraryId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
image: Object.freeze({
@@ -264,9 +294,10 @@ export const assetStub = {
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
height: null,
width: null,
visibility: AssetVisibility.Timeline,
edits: [],
}),
trashed: Object.freeze({
@@ -307,6 +338,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
trashedOffline: Object.freeze({
@@ -347,6 +381,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
archived: Object.freeze({
id: 'asset-id',
@@ -386,6 +423,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
external: Object.freeze({
@@ -425,6 +465,9 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
image1: Object.freeze({
@@ -464,6 +507,9 @@ export const assetStub = {
libraryId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageFrom2015: Object.freeze({
@@ -502,6 +548,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
video: Object.freeze({
@@ -542,6 +591,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
livePhotoMotionAsset: Object.freeze({
@@ -559,7 +611,10 @@ export const assetStub = {
files: [] as AssetFile[],
libraryId: null,
visibility: AssetVisibility.Hidden,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset',
@@ -577,7 +632,10 @@ export const assetStub = {
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
@@ -597,7 +655,10 @@ export const assetStub = {
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({
id: 'asset-with-favorite-id',
@@ -641,6 +702,9 @@ export const assetStub = {
isOffline: false,
tags: [],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
sidecar: Object.freeze({
@@ -676,6 +740,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
sidecarWithoutExt: Object.freeze({
@@ -708,6 +775,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
hasEncodedVideo: Object.freeze({
@@ -747,6 +817,9 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
hasFileExtension: Object.freeze({
@@ -783,6 +856,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageDng: Object.freeze({
@@ -823,6 +899,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageHif: Object.freeze({
@@ -863,6 +942,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
panoramaTif: Object.freeze({
id: 'asset-id',
@@ -902,5 +984,110 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
withCropEdit: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [
{
action: AssetEditAction.Crop,
parameters: {
width: 1512,
height: 1152,
x: 216,
y: 1512,
},
},
] as AssetEditActionItem[],
}),
withoutEdits: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
files: editedFiles,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [],
}),
};
+8
View File
@@ -25,6 +25,7 @@ export const faceStub = {
deletedAt: new Date(),
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
primaryFace1: Object.freeze({
id: 'assetFaceId2',
@@ -43,6 +44,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
mergeFace1: Object.freeze({
id: 'assetFaceId3',
@@ -61,6 +63,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson1: Object.freeze({
id: 'assetFaceId8',
@@ -79,6 +82,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson2: Object.freeze({
id: 'assetFaceId9',
@@ -97,6 +101,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif1: Object.freeze({
id: 'assetFaceId9',
@@ -114,6 +119,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif2: Object.freeze({
id: 'assetFaceId9',
@@ -131,6 +137,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
withBirthDate: Object.freeze({
id: 'assetFaceId10',
@@ -148,5 +155,6 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
};
+7
View File
@@ -142,6 +142,11 @@ export const sharedLinkStub = {
rating: 3,
updatedAt: today,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
sharedLinks: [],
faces: [],
@@ -152,6 +157,8 @@ export const sharedLinkStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
],
albumId: null,
+21
View File
@@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
@@ -68,6 +69,7 @@ import { UserTable } from 'src/schema/tables/user.table';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { MetadataService } from 'src/services/metadata.service';
import { SyncService } from 'src/services/sync.service';
import { UploadFile } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
@@ -179,6 +181,12 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { asset, result };
}
async newMetadata(dto: Insertable<AssetMetadataTable>) {
const { assetId, ...item } = dto;
const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]);
return { metadata: dto, result };
}
async newAssetFile(dto: Insertable<AssetFileTable>) {
const result = await this.get(AssetRepository).upsertFile(dto);
return { result };
@@ -573,6 +581,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { assetId: string }) =>
imageWidth: assetFace.imageWidth ?? 10,
personId: assetFace.personId ?? null,
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
isVisible: assetFace.isVisible ?? true,
};
return {
@@ -739,6 +748,17 @@ const loginResponse = (): LoginResponseDto => {
};
};
const uploadFile = (file: Partial<UploadFile> = {}) => {
return {
uuid: newUuid(),
checksum: randomBytes(32),
originalPath: '/path/to/file.jpg',
originalName: 'file.jpg',
size: 123_456,
...file,
};
};
export const mediumFactory = {
assetInsert,
assetFaceInsert,
@@ -753,4 +773,5 @@ export const mediumFactory = {
loginDetails,
loginResponse,
tagInsert,
uploadFile,
};
@@ -87,4 +87,64 @@ describe(AssetRepository.name, () => {
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
});
});
describe('unlockProperties', () => {
it('should unlock one property', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal', 'description'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
await sut.unlockProperties(asset.id, ['dateTimeOriginal']);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['description'] });
});
it('should unlock all properties', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal', 'description'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
await sut.unlockProperties(asset.id, ['description', 'dateTimeOriginal']);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
});
});
});
@@ -0,0 +1,100 @@
import { Kysely } from 'kysely';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, {
database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetService.name, () => {
describe('uploadAsset', () => {
it('should work', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect(
sut.uploadAsset(
auth,
{
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
},
file,
),
).resolves.toEqual({
id: expect.any(String),
status: AssetMediaStatus.CREATED,
});
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', {
asset: expect.objectContaining({ deviceAssetId: 'some-id' }),
});
});
it('should work with an empty metadata list', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect(
sut.uploadAsset(
auth,
{
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
metadata: [],
},
file,
),
).resolves.toEqual({
id: expect.any(String),
status: AssetMediaStatus.CREATED,
});
});
});
});
@@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
@@ -430,4 +430,177 @@ describe(AssetService.name, () => {
);
});
});
describe('upsertBulkMetadata', () => {
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }];
await sut.upsertBulkMetadata(auth, { items });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(1);
expect(metadata[0]).toEqual(
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }),
);
});
it('should work on conflict', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } });
// verify existing metadata
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }),
]);
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }];
await sut.upsertBulkMetadata(auth, { items });
// verify updated metadata
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }),
]);
});
it('should work with multiple assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const items = [
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } },
];
await sut.upsertBulkMetadata(auth, { items });
const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id);
expect(metadata1).toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }),
]);
const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id);
expect(metadata2).toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }),
]);
});
it('should work with multiple metadata for the same asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const items = [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } },
];
await sut.upsertBulkMetadata(auth, { items });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: AssetMetadataKey.MobileApp,
value: { iCloudId: 'id1' },
}),
expect.objectContaining({
key: 'some-other-key',
value: { foo: 'bar' },
}),
]),
);
});
});
describe('deleteBulkMetadata', () => {
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } });
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(0);
});
it('should work even if the item does not exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(0);
});
it('should work with multiple assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } });
await sut.deleteBulkMetadata(auth, {
items: [
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp },
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp },
],
});
await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]);
await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]);
});
it('should work with multiple metadata for the same asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
await sut.deleteBulkMetadata(auth, {
items: [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp },
{ assetId: asset.id, key: 'some-other-key' },
],
});
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]);
});
it('should not delete unspecified keys', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
await sut.deleteBulkMetadata(auth, {
items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }],
});
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
});
});
});
@@ -57,6 +57,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Test OCR',
textScore: 0.95,
isVisible: true,
x1: 10,
y1: 10,
x2: 50,
@@ -106,6 +107,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'One',
textScore: 0.9,
isVisible: true,
x1: 0,
y1: 1,
x2: 2,
@@ -121,6 +123,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Two',
textScore: 0.89,
isVisible: true,
x1: 8,
y1: 9,
x2: 10,
@@ -136,6 +139,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Three',
textScore: 0.88,
isVisible: true,
x1: 16,
y1: 17,
x2: 18,
@@ -151,6 +155,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Four',
textScore: 0.87,
isVisible: true,
x1: 24,
y1: 25,
x2: 26,
@@ -166,6 +171,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Five',
textScore: 0.86,
isVisible: true,
x1: 32,
y1: 33,
x2: 34,
@@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: null,
stackId: null,
libraryId: null,
width: 1920,
height: 1080,
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: SyncEntityType.AlbumAssetCreateV1,
},
@@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
width: 1920,
height: 1080,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
@@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: 'AssetV1',
},
@@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: null,
height: null,
},
type: SyncEntityType.PartnerAssetV1,
},
@@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertExif: vitest.fn(),
updateAllExif: vitest.fn(),
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
unlockProperties: vitest.fn().mockResolvedValue([]),
upsertJobStatus: vitest.fn(),
getForCopy: vitest.fn(),
getByDayOfYear: vitest.fn(),
@@ -44,8 +45,10 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
updateByLibraryId: vitest.fn(),
getFileSamples: vitest.fn(),
getMetadata: vitest.fn(),
upsertMetadata: vitest.fn(),
getMetadataByKey: vitest.fn(),
upsertMetadata: vitest.fn(),
upsertBulkMetadata: vitest.fn(),
deleteMetadataByKey: vitest.fn(),
deleteBulkMetadata: vitest.fn(),
};
};
+13
View File
@@ -1,6 +1,7 @@
import {
Activity,
ApiKey,
AssetFile,
AuthApiKey,
AuthSharedLink,
AuthUser,
@@ -250,6 +251,8 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
...asset,
});
@@ -358,6 +361,7 @@ const assetOcrFactory = (
boxScore?: number;
textScore?: number;
text?: string;
isVisible?: boolean;
} = {},
) => ({
id: newUuid(),
@@ -373,13 +377,22 @@ const assetOcrFactory = (
boxScore: 0.95,
textScore: 0.92,
text: 'Sample Text',
isVisible: true,
...ocr,
});
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
...file,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
asset: assetFactory,
assetFile: assetFileFactory,
assetOcr: assetOcrFactory,
auth: authFactory,
authApiKey: authApiKeyFactory,
+4
View File
@@ -20,6 +20,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -217,6 +218,7 @@ export type ServiceOverrides = {
app: AppRepository;
audit: AuditRepository;
asset: AssetRepository;
assetEdit: AssetEditRepository;
assetJob: AssetJobRepository;
config: ConfigRepository;
cron: CronRepository;
@@ -291,6 +293,7 @@ export const getMocks = () => {
album: automock(AlbumRepository, { strict: false }),
albumUser: automock(AlbumUserRepository),
asset: newAssetRepositoryMock(),
assetEdit: automock(AssetEditRepository),
assetJob: automock(AssetJobRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
@@ -359,6 +362,7 @@ export const newTestService = <T extends BaseService>(
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
overrides.app || (mocks.app as As<AppRepository>),
overrides.asset || (mocks.asset as As<AssetRepository>),
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.audit || (mocks.audit as As<AuditRepository>),
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),