Files
Immich/server/test/medium/specs/repositories/asset.repository.spec.ts
T
renovate[bot]andCodex 7fb7699c06 chore(deps): update dependency eslint-plugin-unicorn to v78 (#32588)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 72e2bc8b0bc4a7e0a84ac16c0bde7f5e538729fb)
2026-10-28 20:41:28 +03:00

574 lines
20 KiB
TypeScript

import { Kysely } from 'kysely';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { DB } from 'src/schema/index.js';
import { BaseService } from 'src/services/base.service.js';
import { newMediumService } from 'test/medium.factory.js';
import { factory } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(AssetRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
// Metadata extraction is repeatable: probing improves, files get repaired,
// and a re-run has to be able to correct what an earlier run stored.
const audioRow = (assetId: string, n: number) => ({
assetId,
bitrate: 100_000 + n,
index: n,
profile: n,
codecName: `codec-${n}`,
});
const videoRow = (assetId: string, n: number) => ({
assetId,
bitrate: 200_000 + n,
frameCount: 300 + n,
timeBase: 600 + n,
index: n,
profile: n,
level: n,
colorPrimaries: n,
colorTransfer: n,
colorMatrix: n,
dvProfile: n,
dvLevel: n,
dvBlSignalCompatibilityId: n,
codecName: `vcodec-${n}`,
formatName: `format-${n}`,
formatLongName: `format long ${n}`,
pixelFormat: `pixfmt-${n}`,
});
const keyframeRow = (assetId: string, n: number) => ({
assetId,
pts: [n],
accDuration: [n],
ownDuration: [n],
totalDuration: 1000 + n,
packetCount: 10 + n,
outputFrames: 20 + n,
});
describe(AssetRepository.name, () => {
describe('getByDayOfYear', () => {
it('should include every year from one through twenty years ago on the same local calendar day', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const target = { year: 2026, month: 10, day: 18 };
const expectedAssetIds = [];
for (let yearsAgo = 0; yearsAgo <= 21; yearsAgo++) {
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: `${target.year - yearsAgo}-10-18T12:00:00.000Z`,
});
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
if (yearsAgo >= 1 && yearsAgo <= 20) {
expectedAssetIds.push(asset.id);
}
}
for (const dto of [
{ ownerId: user.id, localDateTime: '2025-10-17T23:59:59.999Z' },
{ ownerId: user.id, localDateTime: '2025-10-19T00:00:00.000Z' },
{ ownerId: otherUser.id, localDateTime: '2025-10-18T12:00:00.000Z' },
]) {
const { asset } = await ctx.newAsset(dto);
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
}
const memories = await sut.getByDayOfYear([user.id], target);
expect(memories.map(({ year }) => year)).toEqual(Array.from({ length: 20 }, (_, index) => 2025 - index));
expect(memories.flatMap(({ assets }) => assets.map(({ id }) => id))).toEqual(expectedAssetIds);
});
it.each([2028, 2104])('should skip non-leap years on February 29th in %i', async (year) => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const expectedYears = [];
for (let yearsAgo = 1; yearsAgo <= 20; yearsAgo++) {
const assetYear = year - yearsAgo;
const isLeapYear = assetYear % 4 === 0 && (assetYear % 100 !== 0 || assetYear % 400 === 0);
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: `${assetYear}-${isLeapYear ? '02-29' : '03-01'}T12:00:00.000Z`,
});
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
if (isLeapYear) {
expectedYears.push(assetYear);
}
}
const memories = await sut.getByDayOfYear([user.id], { year, month: 2, day: 29 });
expect(memories.map(({ year }) => year)).toEqual(expectedYears);
});
});
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],
}),
);
});
it('should order assets by originalFileName when fileCreatedAt is the same (takenAt)', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
// create all the fake photos
const [
{ asset: time1DSC0001Asset },
{ asset: time1DSC0002Asset },
{ asset: time2DSC0003Asset },
{ asset: time2DSC0004Asset },
] = await Promise.all([
// both at 12:30AM
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
localDateTime: new Date('2026-03-09T00:30:00.000Z'),
originalFileName: 'DSC0001.jpg',
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
localDateTime: new Date('2026-03-09T00:30:00.000Z'),
originalFileName: 'DSC0002.jpg',
}),
// both at 1:45AM
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T01:45:00.000Z'),
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
originalFileName: 'DSC0003.jpg',
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T01:45:00.000Z'),
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
originalFileName: 'DSC0004.jpg',
}),
]);
// even though im not gonna do anything with these it's required!
await Promise.all([
ctx.newExif({ assetId: time1DSC0001Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time1DSC0002Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time2DSC0003Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time2DSC0004Asset.id, timeZone: 'UTC+2' }),
]);
// check the values given by the bucket when descending
const descendingBucket = await sut.getTimeBucket(
'2026-03-01',
{
order: AssetOrder.Desc,
userIds: [user.id],
visibility: AssetVisibility.Timeline,
orderBy: AssetOrderBy.TakenAt,
},
auth,
);
// make sure they're ordered correctly
expect(JSON.parse(descendingBucket.assets)).toEqual(
expect.objectContaining({
id: [time2DSC0004Asset.id, time2DSC0003Asset.id, time1DSC0002Asset.id, time1DSC0001Asset.id],
}),
);
// now do the same when ascending
const ascendingBucket = await sut.getTimeBucket(
'2026-03-01',
{
order: AssetOrder.Asc,
userIds: [user.id],
visibility: AssetVisibility.Timeline,
orderBy: AssetOrderBy.TakenAt,
},
auth,
);
expect(JSON.parse(ascendingBucket.assets)).toEqual(
expect.objectContaining({
id: [time1DSC0001Asset.id, time1DSC0002Asset.id, time2DSC0003Asset.id, time2DSC0004Asset.id],
}),
);
});
it('should order assets by originalFileName when fileCreatedAt is the same (createdAt)', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
// create all the fake photos
const [
{ asset: time1DSC0001Asset },
{ asset: time1DSC0002Asset },
{ asset: time2DSC0003Asset },
{ asset: time2DSC0004Asset },
] = await Promise.all([
// createdAt = uploadedAt, fileCreatedAt = file metadata
// both at 12:30AM
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
localDateTime: new Date('2026-03-09T00:30:00.000Z'),
createdAt: new Date('2026-03-09T00:30:00.000Z'),
originalFileName: 'DSC0001.jpg',
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
localDateTime: new Date('2026-03-09T00:30:00.000Z'),
createdAt: new Date('2026-03-09T00:30:00.000Z'),
originalFileName: 'DSC0002.jpg',
}),
// both at 1:45AM
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T01:45:00.000Z'),
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
createdAt: new Date('2026-03-09T01:45:00.000Z'),
originalFileName: 'DSC0003.jpg',
}),
ctx.newAsset({
ownerId: user.id,
fileCreatedAt: new Date('2026-03-09T01:45:00.000Z'),
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
createdAt: new Date('2026-03-09T01:45:00.000Z'),
originalFileName: 'DSC0004.jpg',
}),
]);
// even though im not gonna do anything with these it's required!
await Promise.all([
ctx.newExif({ assetId: time1DSC0001Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time1DSC0002Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time2DSC0003Asset.id, timeZone: 'UTC+2' }),
ctx.newExif({ assetId: time2DSC0004Asset.id, timeZone: 'UTC+2' }),
]);
// check the values given by the bucket when descending
const descendingBucket = await sut.getTimeBucket(
'2026-03-01',
{
order: AssetOrder.Desc,
userIds: [user.id],
visibility: AssetVisibility.Timeline,
orderBy: AssetOrderBy.CreatedAt,
},
auth,
);
// make sure they're ordered correctly
expect(JSON.parse(descendingBucket.assets)).toEqual(
expect.objectContaining({
id: [time2DSC0004Asset.id, time2DSC0003Asset.id, time1DSC0002Asset.id, time1DSC0001Asset.id],
}),
);
// now do the same when ascending
const ascendingBucket = await sut.getTimeBucket(
'2026-03-01',
{
order: AssetOrder.Asc,
userIds: [user.id],
visibility: AssetVisibility.Timeline,
orderBy: AssetOrderBy.CreatedAt,
},
auth,
);
expect(JSON.parse(ascendingBucket.assets)).toEqual(
expect.objectContaining({
id: [time1DSC0001Asset.id, time1DSC0002Asset.id, time2DSC0003Asset.id, time2DSC0004Asset.id],
}),
);
});
});
describe('upsertExif', () => {
it('should replace stored audio metadata on a second extraction', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.upsertExif({
exif: { assetId: asset.id, description: 'first' },
audio: audioRow(asset.id, 2),
lockedPropertiesBehavior: 'skip',
});
await sut.upsertExif({
exif: { assetId: asset.id, description: 'second' },
audio: audioRow(asset.id, 1),
lockedPropertiesBehavior: 'skip',
});
await expect(
ctx.database.selectFrom('asset_audio').selectAll().where('assetId', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual(audioRow(asset.id, 1));
});
it('should replace stored video metadata on a second extraction', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.upsertExif({
exif: { assetId: asset.id, description: 'first' },
video: videoRow(asset.id, 2),
lockedPropertiesBehavior: 'skip',
});
await sut.upsertExif({
exif: { assetId: asset.id, description: 'second' },
video: videoRow(asset.id, 1),
lockedPropertiesBehavior: 'skip',
});
await expect(
ctx.database.selectFrom('asset_video').selectAll().where('assetId', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual(videoRow(asset.id, 1));
});
it('should replace stored keyframe metadata on a second extraction', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.upsertExif({
exif: { assetId: asset.id, description: 'first' },
keyframes: keyframeRow(asset.id, 2),
lockedPropertiesBehavior: 'skip',
});
await sut.upsertExif({
exif: { assetId: asset.id, description: 'second' },
keyframes: keyframeRow(asset.id, 1),
lockedPropertiesBehavior: 'skip',
});
await expect(
ctx.database.selectFrom('asset_keyframe').selectAll().where('assetId', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual(keyframeRow(asset.id, 1));
});
// A probe that could not read a stream sends no object at all, and that must
// not be read as "delete what is already known".
it('should leave stored media metadata alone when an extraction omits it', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.upsertExif({
exif: { assetId: asset.id, description: 'first' },
audio: audioRow(asset.id, 2),
lockedPropertiesBehavior: 'skip',
});
await sut.upsertExif({
exif: { assetId: asset.id, description: 'second' },
lockedPropertiesBehavior: 'skip',
});
await expect(
ctx.database.selectFrom('asset_audio').selectAll().where('assetId', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual(audioRow(asset.id, 2));
});
it('should append to locked columns', 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'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
await sut.upsertExif({
exif: { assetId: asset.id, lockedProperties: ['description'] },
lockedPropertiesBehavior: 'append',
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
});
it('should deduplicate locked columns', 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.upsertExif({
exif: { assetId: asset.id, lockedProperties: ['description'] },
lockedPropertiesBehavior: 'append',
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).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 });
});
});
describe('createAll', () => {
it('should return an empty array when given an empty input', async () => {
const { sut } = setup();
await expect(sut.createAll([])).resolves.toStrictEqual([]);
});
});
});