Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-03-16 13:00:41 +01:00
539 changed files with 24437 additions and 10200 deletions
+42
View File
@@ -0,0 +1,42 @@
import { Selectable } from 'kysely';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { build } from 'test/factories/builder.factory';
import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class ActivityFactory {
#user!: UserFactory;
private constructor(private value: Selectable<ActivityTable>) {}
static create(dto: ActivityLike = {}) {
return ActivityFactory.from(dto).build();
}
static from(dto: ActivityLike = {}) {
const userId = dto.userId ?? newUuid();
return new ActivityFactory({
albumId: newUuid(),
assetId: null,
comment: null,
createdAt: newDate(),
id: newUuid(),
isLiked: false,
userId,
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
}).user({ id: userId });
}
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#user = build(UserFactory.from(dto), builder);
this.value.userId = this.#user.build().id;
return this;
}
build() {
return { ...this.value, user: this.#user.build() };
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Selectable } from 'kysely';
import { Permission } from 'src/enum';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { build } from 'test/factories/builder.factory';
import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class ApiKeyFactory {
#user!: UserFactory;
private constructor(private value: Selectable<ApiKeyTable>) {}
static create(dto: ApiKeyLike = {}) {
return ApiKeyFactory.from(dto).build();
}
static from(dto: ApiKeyLike = {}) {
const userId = dto.userId ?? newUuid();
return new ApiKeyFactory({
createdAt: newDate(),
id: newUuid(),
key: Buffer.from('api-key-buffer'),
name: 'API Key',
permissions: [Permission.All],
updatedAt: newDate(),
updateId: newUuidV7(),
userId,
...dto,
}).user({ id: userId });
}
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#user = build(UserFactory.from(dto), builder);
this.value.userId = this.#user.build().id;
return this;
}
build() {
return { ...this.value, user: this.#user.build() };
}
}
-1
View File
@@ -55,7 +55,6 @@ export class AssetFactory {
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
+14 -3
View File
@@ -1,12 +1,16 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { build } from 'test/factories/builder.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newUuid } from 'test/small.factory';
export class AuthFactory {
#user: UserFactory;
#sharedLink?: SharedLinkFactory;
#apiKey?: ApiKeyFactory;
#session?: AuthDto['session'];
private constructor(user: UserFactory) {
this.#user = user;
@@ -20,8 +24,8 @@ export class AuthFactory {
return new AuthFactory(UserFactory.from(dto));
}
apiKey() {
// TODO
apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder<ApiKeyFactory>) {
this.#apiKey = build(ApiKeyFactory.from(dto), builder);
return this;
}
@@ -30,6 +34,11 @@ export class AuthFactory {
return this;
}
session(dto: Partial<AuthDto['session']> = {}) {
this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto };
return this;
}
build(): AuthDto {
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
@@ -43,6 +52,8 @@ export class AuthFactory {
quotaSizeInBytes,
},
sharedLink: this.#sharedLink?.build(),
apiKey: this.#apiKey?.build(),
session: this.#session,
};
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Selectable } from 'kysely';
import { MemoryType } from 'src/enum';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class MemoryFactory {
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<MemoryTable>) {}
static create(dto: MemoryLike = {}) {
return MemoryFactory.from(dto).build();
}
static from(dto: MemoryLike = {}) {
return new MemoryFactory({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 },
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
...dto,
});
}
asset(asset: AssetLike, builder?: FactoryBuilder<AssetFactory>) {
this.#assets.push(build(AssetFactory.from(asset), builder));
return this;
}
build() {
return { ...this.value, assets: this.#assets.map((asset) => asset.build()) };
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Selectable } from 'kysely';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { build } from 'test/factories/builder.factory';
import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class PartnerFactory {
#sharedWith!: UserFactory;
#sharedBy!: UserFactory;
private constructor(private value: Selectable<PartnerTable>) {}
static create(dto: PartnerLike = {}) {
return PartnerFactory.from(dto).build();
}
static from(dto: PartnerLike = {}) {
const sharedById = dto.sharedById ?? newUuid();
const sharedWithId = dto.sharedWithId ?? newUuid();
return new PartnerFactory({
createdAt: newDate(),
createId: newUuidV7(),
inTimeline: true,
sharedById,
sharedWithId,
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
})
.sharedBy({ id: sharedById })
.sharedWith({ id: sharedWithId });
}
sharedWith(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#sharedWith = build(UserFactory.from(dto), builder);
this.value.sharedWithId = this.#sharedWith.build().id;
return this;
}
sharedBy(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#sharedBy = build(UserFactory.from(dto), builder);
this.value.sharedById = this.#sharedBy.build().id;
return this;
}
build() {
return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() };
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Selectable } from 'kysely';
import { SessionTable } from 'src/schema/tables/session.table';
import { SessionLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class SessionFactory {
private constructor(private value: Selectable<SessionTable>) {}
static create(dto: SessionLike = {}) {
return SessionFactory.from(dto).build();
}
static from(dto: SessionLike = {}) {
return new SessionFactory({
appVersion: null,
createdAt: newDate(),
deviceOS: 'android',
deviceType: 'mobile',
expiresAt: null,
id: newUuid(),
isPendingSyncReset: false,
parentId: null,
pinExpiresAt: null,
token: Buffer.from('abc123'),
updateId: newUuidV7(),
updatedAt: newDate(),
userId: newUuid(),
...dto,
});
}
build() {
return { ...this.value };
}
}
@@ -51,12 +51,14 @@ export class SharedLinkFactory {
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
this.#album = build(AlbumFactory.from(dto), builder);
this.value.type = SharedLinkType.Album;
return this;
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.#assets.push(asset);
this.value.type = SharedLinkType.Individual;
return this;
}
+10
View File
@@ -1,12 +1,17 @@
import { Selectable } from 'kysely';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -24,3 +29,8 @@ export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
export type MemoryLike = Partial<Selectable<MemoryTable>>;
export type PartnerLike = Partial<Selectable<PartnerTable>>;
export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
+9 -1
View File
@@ -112,7 +112,7 @@ export const probeStub = {
}),
videoStream40Mbps: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000, codecName: 'h264' }],
}),
videoStreamMTS: Object.freeze<VideoInfo>({
...probeStubDefault,
@@ -221,6 +221,14 @@ export const probeStub = {
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
}),
audioStreamMp3: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
}),
audioStreamOpus: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
}),
audioStreamUnknown: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [
+2 -82
View File
@@ -1,7 +1,6 @@
import { UserAdmin } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -83,86 +82,7 @@ export const sharedLinkStub = {
showExif: false,
description: null,
password: null,
assets: [
{
id: 'id_1',
status: AssetStatus.Active,
owner: undefined as unknown as UserAdmin,
ownerId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1',
type: AssetType.Video,
originalPath: 'fake_path/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
fileModifiedAt: today,
fileCreatedAt: today,
localDateTime: today,
createdAt: today,
updatedAt: today,
isFavorite: false,
isArchived: false,
isExternal: false,
isOffline: false,
files: [],
thumbhash: null,
encodedVideoPath: '',
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
exifInfo: {
projectionType: null,
livePhotoCID: null,
assetId: 'id_1',
description: 'description',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
timeZone: 'America/Los_Angeles',
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: '1/16',
fps: 100,
profileDescription: 'sRGB',
bitsPerSample: 8,
colorspace: 'sRGB',
autoStackId: null,
rating: 3,
updatedAt: today,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
tags: [],
},
sharedLinks: [],
faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
isEdited: false,
},
],
assets: [],
albumId: null,
album: null,
slug: null,
+4 -4
View File
@@ -55,15 +55,15 @@ export const tagStub = {
export const tagResponseStub = {
tag1: Object.freeze<TagResponseDto>({
id: 'tag-1',
createdAt: new Date('2021-01-01T00:00:00Z'),
updatedAt: new Date('2021-01-01T00:00:00Z'),
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-01T00:00:00.000Z',
name: 'Tag1',
value: 'Tag1',
}),
color1: Object.freeze<TagResponseDto>({
id: 'tag-1',
createdAt: new Date('2021-01-01T00:00:00Z'),
updatedAt: new Date('2021-01-01T00:00:00Z'),
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-01T00:00:00.000Z',
color: '#000000',
name: 'Tag1',
value: 'Tag1',
+175 -2
View File
@@ -1,7 +1,15 @@
import { Selectable } from 'kysely';
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { MemoryFactory } from 'test/factories/memory.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { UserFactory } from 'test/factories/user.factory';
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
return {
@@ -12,6 +20,7 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
isExternal: asset.isExternal,
checksum: asset.checksum,
timeZone: asset.exifInfo.timeZone,
visibility: asset.visibility,
fileCreatedAt: asset.fileCreatedAt,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
@@ -46,6 +55,170 @@ export const getForFacialRecognitionJob = (
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
) => ({
...face,
asset,
asset: asset
? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() }
: null,
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
});
export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
const copiedEntity = structuredClone(entity);
for (const [key, value] of Object.entries(copiedEntity)) {
if (value instanceof Date) {
Object.assign(copiedEntity, { [key]: value.toISOString() });
continue;
}
}
return copiedEntity as ShallowDehydrateObject<T>;
};
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
...album,
assets: album.assets.map((asset) =>
getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }),
),
albumUsers: album.albumUsers.map((albumUser) => ({
...albumUser,
createdAt: albumUser.createdAt.toISOString(),
user: getDehydrated(albumUser.user),
})),
owner: getDehydrated(album.owner),
sharedLinks: album.sharedLinks.map((sharedLink) => getDehydrated(sharedLink)),
});
export const getForActivity = (activity: Selectable<ActivityTable> & { user: ReturnType<UserFactory['build']> }) => ({
...activity,
user: getDehydrated(activity.user),
});
export const getForAsset = (asset: ReturnType<AssetFactory['build']>) => {
return {
...asset,
faces: asset.faces.map((face) => ({
...getDehydrated(face),
person: face.person ? getDehydrated(face.person) : null,
})),
owner: getDehydrated(asset.owner),
stack: asset.stack
? { ...getDehydrated(asset.stack), assets: asset.stack.assets.map((asset) => getDehydrated(asset)) }
: null,
files: asset.files.map((file) => getDehydrated(file)),
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
};
};
export const getForPartner = (
partner: Selectable<PartnerTable> & Record<'sharedWith' | 'sharedBy', ReturnType<UserFactory['build']>>,
) => ({
...partner,
sharedBy: getDehydrated(partner.sharedBy),
sharedWith: getDehydrated(partner.sharedWith),
});
export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
...memory,
assets: memory.assets.map((asset) => getDehydrated(asset)),
});
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
checksum: asset.checksum,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isExternal: asset.isExternal,
visibility: asset.visibility,
libraryId: asset.libraryId,
livePhotoVideoId: asset.livePhotoVideoId,
localDateTime: asset.localDateTime,
originalFileName: asset.originalFileName,
originalPath: asset.originalPath,
ownerId: asset.ownerId,
type: asset.type,
width: asset.width,
height: asset.height,
faces: asset.faces.map((face) => getDehydrated(face)),
files: asset.files.map((file) => getDehydrated(file)),
});
export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
originalFileName: asset.originalFileName,
originalPath: asset.originalPath,
ownerId: asset.ownerId,
thumbhash: asset.thumbhash,
type: asset.type,
files: asset.files.map((file) => getDehydrated(file)),
exifInfo: getDehydrated(asset.exifInfo),
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
});
export const getForAssetFace = (face: ReturnType<AssetFaceFactory['build']>) => ({
...face,
person: face.person ? getDehydrated(face.person) : null,
});
export const getForDetectedFaces = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
exifInfo: getDehydrated(asset.exifInfo),
faces: asset.faces.map((face) => getDehydrated(face)),
files: asset.files.map((file) => getDehydrated(file)),
});
export const getForSidecarWrite = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
originalPath: asset.originalPath,
files: asset.files.map((file) => getDehydrated(file)),
exifInfo: getDehydrated(asset.exifInfo),
});
export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
libraryId: asset.libraryId,
ownerId: asset.ownerId,
livePhotoVideoId: asset.livePhotoVideoId,
originalPath: asset.originalPath,
isOffline: asset.isOffline,
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
files: asset.files.map((file) => getDehydrated(file)),
stack: asset.stack
? {
...getDehydrated(asset.stack),
assets: asset.stack.assets.filter(({ id }) => id !== asset.stack?.primaryAssetId).map(({ id }) => ({ id })),
}
: null,
});
export const getForStack = (stack: ReturnType<StackFactory['build']>) => ({
...stack,
assets: stack.assets.map((asset) => ({
...getDehydrated(asset),
exifInfo: getDehydrated(asset.exifInfo),
})),
});
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) => ({
...getDehydrated(asset),
exifInfo: getDehydrated(asset.exifInfo),
});
export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build']>) => ({
...sharedLink,
assets: sharedLink.assets.map((asset) => ({
...getDehydrated({ ...getForAsset(asset) }),
exifInfo: getDehydrated(asset.exifInfo),
})),
album: sharedLink.album
? {
...getDehydrated(sharedLink.album),
owner: getDehydrated(sharedLink.album.owner),
assets: sharedLink.album.assets.map((asset) => getDehydrated(asset)),
}
: null,
});
+12
View File
@@ -3,6 +3,7 @@ import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { resolve } from 'node:path';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
@@ -78,6 +79,9 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, wait } from 'test/utils';
import { Mocked } from 'vitest';
// eslint-disable-next-line unicorn/prefer-module
export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets');
interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T;
}
@@ -233,6 +237,14 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { albumUser: { albumId, userId, role }, result };
}
async softDeleteAsset(assetId: string) {
await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute();
}
async softDeleteAlbum(albumId: string) {
await this.database.updateTable('album').set({ deletedAt: new Date() }).where('id', '=', albumId).execute();
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatusTable>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
@@ -1,9 +1,11 @@
import { Kysely } from 'kysely';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@@ -22,6 +24,61 @@ beforeAll(async () => {
});
describe(AssetRepository.name, () => {
describe('getTimeBucket', () => {
it('should order assets by local day first and fileCreatedAt within each day', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] =
await Promise.all([
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
localDateTime: new Date('2026-03-08T22:30:00.000Z'),
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'),
localDateTime: new Date('2026-03-09T01:30:00.000Z'),
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'),
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
}),
]);
await Promise.all([
ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }),
ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }),
]);
const descendingBucket = await sut.getTimeBucket(
'2026-03-01',
{ order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline },
auth,
);
expect(JSON.parse(descendingBucket.assets)).toEqual(
expect.objectContaining({
id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id],
}),
);
const ascendingBucket = await sut.getTimeBucket(
'2026-03-01',
{ order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline },
auth,
);
expect(JSON.parse(ascendingBucket.assets)).toEqual(
expect.objectContaining({
id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id],
}),
);
});
});
describe('upsertExif', () => {
it('should append to locked columns', async () => {
const { ctx, sut } = setup();
@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
expect(result).toEqual({ total: 0 });
});
});
describe('withStacked option', () => {
it('should exclude stacked assets when withStacked is false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
});
@@ -95,6 +95,469 @@ describe(SharedLinkService.name, () => {
});
});
describe('getAll', () => {
it('should return all shared links even when they share the same createdAt', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sameTimestamp = '2024-01-01T00:00:00.000Z';
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
});
it('should return shared links sorted by createdAt in descending order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2021-01-01T00:00:00.000Z',
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2023-01-01T00:00:00.000Z',
});
const link3 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2022-01-01T00:00:00.000Z',
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
expect(result.map((r) => r.id)).toEqual([link2.id, link3.id, link1.id]);
});
it('should not return shared links belonging to other users', async () => {
const { sut, ctx } = setup();
const { user: userA } = await ctx.newUser();
const { user: userB } = await ctx.newUser();
const authA = factory.auth({ user: userA });
const authB = factory.auth({ user: userB });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const linkA = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userA.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userB.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
const resultA = await sut.getAll(authA, {});
expect(resultA).toHaveLength(1);
expect(resultA[0].id).toBe(linkA.id);
const resultB = await sut.getAll(authB, {});
expect(resultB).toHaveLength(1);
expect(resultB[0].id).not.toBe(linkA.id);
});
it('should filter by albumId', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, { albumId: album1.id });
expect(result).toHaveLength(1);
expect(result[0].id).toBe(link1.id);
});
it('should return album shared links with album data', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].album).toBeDefined();
expect(result[0].album!.id).toBe(album.id);
});
it('should return multiple album shared links without sql error from json group by', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
expect(result[0].album).toBeDefined();
expect(result[1].album).toBeDefined();
});
it('should return mixed album and individual shared links together', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const albumLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const albumLink2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const individualLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
const ids = result.map((r) => r.id);
expect(ids).toContain(albumLink.id);
expect(ids).toContain(albumLink2.id);
expect(ids).toContain(individualLink.id);
});
it('should return only the first asset as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const assets = await Promise.all([
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2021-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2023-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2022-01-01T00:00:00.000Z' }),
]);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: assets.map(({ asset }) => asset.id),
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(assets[0].asset.id);
});
});
describe('get', () => {
it('should not return trashed assets for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [visibleAsset.id, trashedAsset.id],
});
const result = await sut.get(auth, sharedLink.id);
expect(result).toBeDefined();
expect(result!.assets).toHaveLength(1);
expect(result!.assets[0].id).toBe(visibleAsset.id);
});
it('should return empty assets when all individually shared assets are trashed', 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.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
assets: [],
});
});
it('should not return trashed assets in a shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: visibleAsset.id });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: trashedAsset.id });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 1 }),
});
});
it('should return an empty asset count when all album assets are trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 0 }),
});
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
await expect(sut.get(auth, sharedLink.id)).rejects.toThrow('Shared link not found');
});
});
describe('getAll', () => {
it('should not return trashed assets as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: trashedAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2020-01-01T00:00:00.000Z',
});
await ctx.softDeleteAsset(trashedAsset.id);
const { asset: visibleAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2021-01-01T00:00:00.000Z',
});
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [trashedAsset.id, visibleAsset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(visibleAsset.id);
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(0);
});
});
it('should remove individually shared asset', async () => {
const { sut, ctx } = setup();
+2 -324
View File
@@ -1,40 +1,7 @@
import {
Activity,
Album,
ApiKey,
AssetFace,
AssetFile,
AuthApiKey,
AuthSharedLink,
AuthUser,
Exif,
Library,
Memory,
Partner,
Person,
Session,
Stack,
Tag,
User,
UserAdmin,
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import {
AssetFileType,
AssetOrder,
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
Permission,
SourceType,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
import { AssetFileType, Permission, UserStatus } from 'src/enum';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@@ -123,41 +90,6 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
};
const partnerFactory = (partner: Partial<Partner> = {}) => {
const sharedBy = userFactory(partner.sharedBy || {});
const sharedWith = userFactory(partner.sharedWith || {});
return {
sharedById: sharedBy.id,
sharedBy,
sharedWithId: sharedWith.id,
sharedWith,
createId: newUuidV7(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
inTimeline: true,
...partner,
};
};
const sessionFactory = (session: Partial<Session> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deviceOS: 'android',
deviceType: 'mobile',
token: Buffer.from('abc123'),
parentId: null,
expiresAt: null,
userId: newUuid(),
pinExpiresAt: newDate(),
isPendingSyncReset: false,
appVersion: session.appVersion ?? null,
...session,
});
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
active: 0,
completed: 0,
@@ -168,35 +100,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...dto,
});
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
const ownerId = newUuid();
return {
id: newUuid(),
primaryAssetId: assets?.[0].id ?? newUuid(),
ownerId,
owner: userFactory(owner ?? { id: ownerId }),
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
...stack,
};
};
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
name: 'Test User',
email: 'test@immich.cloud',
avatarColor: null,
profileImagePath: '',
profileChangedAt: newDate(),
metadata: [
{
key: UserMetadataKey.Onboarding,
value: 'true',
},
] as UserMetadataItem[],
...user,
});
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
const {
id = newUuid(),
@@ -238,72 +141,6 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
};
};
const assetFactory = (
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
) => {
return {
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
};
};
const activityFactory = (activity: Partial<Activity> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
...activity,
};
};
const apiKeyFactory = (apiKey: Partial<ApiKey> = {}) => ({
id: newUuid(),
userId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
name: 'Api Key',
permissions: [Permission.All],
...apiKey,
});
const libraryFactory = (library: Partial<Library> = {}) => ({
id: newUuid(),
createdAt: newDate(),
@@ -319,24 +156,6 @@ const libraryFactory = (library: Partial<Library> = {}) => ({
...library,
});
const memoryFactory = (memory: Partial<Memory> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
});
const versionHistoryFactory = () => ({
id: newUuid(),
createdAt: newDate(),
@@ -403,156 +222,15 @@ const assetOcrFactory = (
...ocr,
});
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
isProgressive: false,
...file,
});
const exifFactory = (exif: Partial<Exif> = {}) => ({
assetId: newUuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: newDate(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: newDate(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...exif,
});
const tagFactory = (tag: Partial<Tag>): Tag => ({
id: newUuid(),
color: null,
createdAt: newDate(),
parentId: null,
updatedAt: newDate(),
value: `tag-${newUuid()}`,
...tag,
});
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
assetId: newUuid(),
boundingBoxX1: 1,
boundingBoxX2: 2,
boundingBoxY1: 1,
boundingBoxY2: 2,
deletedAt: null,
id: newUuid(),
imageHeight: 420,
imageWidth: 42,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
person: person === null ? null : personFactory(person),
...face,
});
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
switch (edit?.action) {
case AssetEditAction.Crop: {
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
}
case AssetEditAction.Mirror: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
}
case AssetEditAction.Rotate: {
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
}
default: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
}
}
};
const personFactory = (person?: Partial<Person>): Person => ({
birthDate: newDate(),
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',
ownerId: newUuid(),
thumbnailPath: '/path/to/person/thumbnail.jpg',
updatedAt: newDate(),
updateId: newUuidV7(),
...person,
});
const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
albumName: 'My Album',
albumThumbnailAssetId: null,
albumUsers: [],
assets: [],
createdAt: newDate(),
deletedAt: null,
description: 'Album description',
id: newUuid(),
isActivityEnabled: false,
order: AssetOrder.Desc,
ownerId: newUuid(),
sharedLinks: [],
updatedAt: newDate(),
updateId: newUuidV7(),
...album,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
asset: assetFactory,
assetFile: assetFileFactory,
assetOcr: assetOcrFactory,
auth: authFactory,
authApiKey: authApiKeyFactory,
authUser: authUserFactory,
library: libraryFactory,
memory: memoryFactory,
partner: partnerFactory,
queueStatistics: queueStatisticsFactory,
session: sessionFactory,
stack: stackFactory,
user: userFactory,
userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory,
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
exif: exifFactory,
face: faceFactory,
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,
album: albumFactory,
uuid: newUuid,
buffer: () => Buffer.from('this is a fake buffer'),
date: newDate,
+6 -1
View File
@@ -1,10 +1,15 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({
test: {
root: './',
name: 'server:medium',
root: serverRoot,
globals: true,
include: ['test/medium/**/*.spec.ts'],
globalSetup: ['test/medium/globalSetup.ts'],
+6 -1
View File
@@ -1,10 +1,15 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({
test: {
root: './',
name: 'server:unit',
root: serverRoot,
globals: true,
include: ['src/**/*.spec.ts'],
coverage: {