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

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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);
});
});
});

View File

@@ -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();