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

This commit is contained in:
izzy
2026-01-21 17:02:22 +00:00
310 changed files with 18273 additions and 3199 deletions

View File

@@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited,
type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited,
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited,
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
});
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
@@ -86,6 +89,7 @@ export const assetStub = {
make: 'FUJIFILM',
model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR',
isEdited: false,
...asset,
}),
noResizePath: Object.freeze({
@@ -125,6 +129,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
noWebpPath: Object.freeze({
@@ -166,6 +171,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
noThumbhash: Object.freeze({
@@ -204,6 +210,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
primaryImage: Object.freeze({
@@ -252,6 +259,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
image: Object.freeze({
@@ -298,6 +306,7 @@ export const assetStub = {
width: null,
visibility: AssetVisibility.Timeline,
edits: [],
isEdited: false,
}),
trashed: Object.freeze({
@@ -341,6 +350,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
trashedOffline: Object.freeze({
@@ -384,6 +394,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
archived: Object.freeze({
id: 'asset-id',
@@ -426,6 +437,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
external: Object.freeze({
@@ -468,6 +480,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
image1: Object.freeze({
@@ -510,6 +523,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageFrom2015: Object.freeze({
@@ -551,6 +565,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
video: Object.freeze({
@@ -594,6 +609,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
livePhotoMotionAsset: Object.freeze({
@@ -614,6 +630,7 @@ export const assetStub = {
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
livePhotoStillAsset: Object.freeze({
@@ -635,6 +652,7 @@ export const assetStub = {
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
livePhotoWithOriginalFileName: Object.freeze({
@@ -658,6 +676,7 @@ export const assetStub = {
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({
@@ -705,6 +724,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
sidecar: Object.freeze({
@@ -743,6 +763,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
sidecarWithoutExt: Object.freeze({
@@ -778,6 +799,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
hasEncodedVideo: Object.freeze({
@@ -820,6 +842,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
hasFileExtension: Object.freeze({
@@ -859,6 +882,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageDng: Object.freeze({
@@ -902,6 +926,7 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageHif: Object.freeze({
@@ -945,7 +970,9 @@ export const assetStub = {
width: null,
height: null,
edits: [],
isEdited: false,
}),
panoramaTif: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -988,6 +1015,7 @@ export const assetStub = {
height: null,
edits: [],
}),
withCropEdit: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -1043,7 +1071,9 @@ export const assetStub = {
},
},
] as AssetEditActionItem[],
isEdited: true,
}),
withoutEdits: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -1089,5 +1119,6 @@ export const assetStub = {
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [],
isEdited: false,
}),
};

View File

@@ -147,6 +147,7 @@ export const sharedLinkStub = {
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
tags: [],
},
sharedLinks: [],
faces: [],
@@ -159,6 +160,7 @@ export const sharedLinkStub = {
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
isEdited: false,
},
],
albumId: null,

View File

@@ -19,6 +19,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.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 { ConfigRepository } from 'src/repositories/config.repository';
@@ -384,6 +385,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case MemoryRepository:
case NotificationRepository:
@@ -535,6 +537,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
fileModifiedAt: now,
localDateTime: now,
visibility: AssetVisibility.Timeline,
isEdited: false,
};
return {

View File

@@ -0,0 +1,115 @@
import { Kysely } from 'kysely';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetEditRepository } from 'src/repositories/asset-edit.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 { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(AssetEditRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetEditRepository.name, () => {
describe('replaceAll', () => {
it('should set isEdited on insert', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
]);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: true });
});
it('should set isEdited when inserting multiple edits', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: true });
});
it('should keep isEdited when removing some edits', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: true });
await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
]);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: true });
});
it('should set isEdited to false if all edits are deleted', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]);
await sut.replaceAll(asset.id, []);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: false });
});
});
});

View File

@@ -83,6 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
isEdited: asset.isEdited,
},
type: SyncEntityType.AlbumAssetCreateV1,
},

View File

@@ -64,6 +64,7 @@ describe(SyncEntityType.AssetV1, () => {
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
isEdited: asset.isEdited,
},
type: 'AssetV1',
},

View File

@@ -63,6 +63,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
isEdited: asset.isEdited,
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,

View File

@@ -50,6 +50,9 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
createZipStream: vitest.fn(),
createPlainReadStream: vitest.fn(),
createReadStream: vitest.fn(),
createPlainReadStream: vitest.fn(),
createGzip: vitest.fn(),
createGunzip: vitest.fn(),
readFile: vitest.fn(),
readTextFile: vitest.fn(),
createFile: vitest.fn(),

View File

@@ -253,6 +253,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
});
@@ -334,6 +335,7 @@ const assetSidecarWriteFactory = () => {
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
isEdited: false,
},
],
exifInfo: {
@@ -385,6 +387,7 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
...file,
});

View File

@@ -7,7 +7,7 @@ import { NextFunction } from 'express';
import { Kysely } from 'kysely';
import multer from 'multer';
import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { Duplex, Readable, Writable } from 'node:stream';
import { PNG } from 'pngjs';
import postgres from 'postgres';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
@@ -500,6 +500,74 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
} as unknown as ChildProcessWithoutNullStreams;
});
export const mockDuplex = vitest.fn(
(command: string, exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const duplex = new Duplex({
write(_chunk, _encoding, callback) {
callback();
},
read() {},
final(callback) {
callback();
},
});
setImmediate(() => {
if (error) {
duplex.destroy(error as Error);
} else if (exitCode === 0) {
/* eslint-disable unicorn/prefer-single-call */
duplex.push(stdout);
duplex.push(null);
/* eslint-enable unicorn/prefer-single-call */
} else {
duplex.destroy(new Error(`${command} non-zero exit code (${exitCode})\n${stderr}`));
}
});
return duplex;
},
);
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const stdoutStream = new Readable({
read() {
this.push(stdout); // write mock data to stdout
this.push(null); // end stream
},
});
return {
stdout: stdoutStream,
stderr: new Readable({
read() {
this.push(stderr); // write mock data to stderr
this.push(null); // end stream
},
}),
stdin: new Writable({
write(chunk, encoding, callback) {
callback();
},
}),
exitCode,
on: vitest.fn((event, callback: any) => {
if (event === 'close') {
stdoutStream.once('end', () => callback(0));
}
if (event === 'error' && error) {
stdoutStream.once('end', () => callback(error));
}
if (event === 'exit') {
stdoutStream.once('end', () => callback(exitCode));
}
}),
kill: vitest.fn(),
} as unknown as ChildProcessWithoutNullStreams;
});
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
for (const item of items) {
await Promise.resolve();