0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 03:01:32 -05:00

refactor: memory stub (#16704)

This commit is contained in:
Jason Rasmussen 2025-03-07 16:03:34 -05:00 committed by GitHub
parent b0bf4e4fff
commit ce74f765b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 218 additions and 116 deletions

View file

@ -1,5 +1,5 @@
import { sql } from 'kysely';
import { Permission } from 'src/enum';
import { AssetStatus, AssetType, Permission } from 'src/enum';
export type AuthUser = {
id: string;
@ -23,6 +23,38 @@ export type User = {
profileChangedAt: Date;
};
export type Asset = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
id: string;
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: string | null;
fileCreatedAt: Date | null;
fileModifiedAt: Date | null;
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date | null;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
stackId: string | null;
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
};
export type AuthSharedLink = {
id: string;
expiresAt: Date | null;

7
server/src/db.d.ts vendored
View file

@ -4,7 +4,8 @@
*/
import type { ColumnType } from 'kysely';
import { AssetType, Permission, SyncEntityType } from 'src/enum';
import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
@ -231,7 +232,7 @@ export interface Libraries {
export interface Memories {
createdAt: Generated<Timestamp>;
data: Json;
data: OnThisDayData;
deletedAt: Timestamp | null;
hideAt: Timestamp | null;
id: Generated<string>;
@ -240,7 +241,7 @@ export interface Memories {
ownerId: string;
seenAt: Timestamp | null;
showAt: Timestamp | null;
type: string;
type: MemoryType;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}

View file

@ -1,9 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryType } from 'src/enum';
import { MemoryService } from 'src/services/memory.service';
import { authStub } from 'test/fixtures/auth.stub';
import { memoryStub } from 'test/fixtures/memory.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MemoryService.name, () => {
@ -20,174 +17,227 @@ describe(MemoryService.name, () => {
describe('search', () => {
it('should search memories', async () => {
mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]);
await expect(sut.search(authStub.admin, {})).resolves.toEqual(
const [userId] = newUuids();
const asset = factory.asset();
const memory1 = factory.memory({ ownerId: userId, assets: [asset] });
const memory2 = factory.memory({ ownerId: userId });
mocks.memory.search.mockResolvedValue([memory1, memory2]);
await expect(sut.search(factory.auth({ id: userId }), {})).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
expect.objectContaining({ id: 'memoryEmpty', assets: [] }),
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
expect.objectContaining({ id: memory2.id, assets: [] }),
]),
);
});
it('should map ', async () => {
await expect(sut.search(authStub.admin, {})).resolves.toEqual([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
});
});
describe('get', () => {
it('should throw an error when no access', async () => {
await expect(sut.get(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.get(factory.auth(), 'not-found')).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw an error when the memory is not found', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition']));
await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException);
const [memoryId] = newUuids();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memoryId]));
await expect(sut.get(factory.auth(), memoryId)).rejects.toBeInstanceOf(BadRequestException);
});
it('should get a memory by id', async () => {
mocks.memory.get.mockResolvedValue(memoryStub.memory1);
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' });
expect(mocks.memory.get).toHaveBeenCalledWith('memory1');
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1']));
const userId = newUuid();
const memory = factory.memory({ ownerId: userId });
mocks.memory.get.mockResolvedValue(memory);
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
await expect(sut.get(factory.auth({ id: userId }), memory.id)).resolves.toMatchObject({ id: memory.id });
expect(mocks.memory.get).toHaveBeenCalledWith(memory.id);
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id]));
});
});
describe('create', () => {
it('should skip assets the user does not have access to', async () => {
mocks.memory.create.mockResolvedValue(memoryStub.empty);
const [assetId, userId] = newUuids();
const memory = factory.memory({ ownerId: userId });
mocks.memory.create.mockResolvedValue(memory);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
assetIds: ['not-mine'],
memoryAt: new Date(2024),
sut.create(factory.auth({ id: userId }), {
type: memory.type,
data: memory.data,
memoryAt: memory.memoryAt,
isSaved: memory.isSaved,
assetIds: [assetId],
}),
).resolves.toMatchObject({ assets: [] });
expect(mocks.memory.create).toHaveBeenCalledWith(
{
ownerId: 'admin_id',
memoryAt: expect.any(Date),
type: MemoryType.ON_THIS_DAY,
isSaved: undefined,
sendAt: undefined,
data: { year: 2024 },
type: memory.type,
data: memory.data,
ownerId: memory.ownerId,
memoryAt: memory.memoryAt,
isSaved: memory.isSaved,
},
new Set(),
);
});
it('should create a memory', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.create.mockResolvedValue(memoryStub.memory1);
const [assetId, userId] = newUuids();
const asset = factory.asset({ id: assetId, ownerId: userId });
const memory = factory.memory({ assets: [asset] });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.memory.create.mockResolvedValue(memory);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
assetIds: ['asset1'],
memoryAt: new Date(2024, 0, 1),
sut.create(factory.auth({ id: userId }), {
type: memory.type,
data: memory.data,
assetIds: memory.assets.map((asset) => asset.id),
memoryAt: memory.memoryAt,
}),
).resolves.toBeDefined();
expect(mocks.memory.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: userStub.admin.id,
}),
new Set(['asset1']),
expect.objectContaining({ ownerId: userId }),
new Set([assetId]),
);
});
it('should create a memory without assets', async () => {
mocks.memory.create.mockResolvedValue(memoryStub.memory1);
const memory = factory.memory();
mocks.memory.create.mockResolvedValue(memory);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
memoryAt: new Date(2024),
}),
sut.create(factory.auth(), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt }),
).resolves.toBeDefined();
});
});
describe('update', () => {
it('should require access', async () => {
await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf(
await expect(sut.update(factory.auth(), 'not-found', { isSaved: true })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.memory.update).not.toHaveBeenCalled();
});
it('should update a memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.update.mockResolvedValue(memoryStub.memory1);
await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined();
expect(mocks.memory.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true }));
const memory = factory.memory();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.update.mockResolvedValue(memory);
await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined();
expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ isSaved: true }));
});
});
describe('remove', () => {
it('should require access', async () => {
await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.remove(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.memory.delete).not.toHaveBeenCalled();
});
it('should delete a memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined();
expect(mocks.memory.delete).toHaveBeenCalledWith('memory1');
const memoryId = newUuid();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memoryId]));
await expect(sut.remove(factory.auth(), memoryId)).resolves.toBeUndefined();
expect(mocks.memory.delete).toHaveBeenCalledWith(memoryId);
});
});
describe('addAssets', () => {
it('should require memory access', async () => {
await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
const [memoryId, assetId] = newUuids();
await expect(sut.addAssets(factory.auth(), memoryId, { ids: [assetId] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
});
it('should require asset access', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'no_permission', id: 'not-found', success: false },
const assetId = newUuid();
const memory = factory.memory();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
{ error: 'no_permission', id: assetId, success: false },
]);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
});
it('should skip assets already in the memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1);
mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ error: 'duplicate', id: 'asset1', success: false },
const asset = factory.asset();
const memory = factory.memory({ assets: [asset] });
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([
{ error: 'duplicate', id: asset.id, success: false },
]);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
});
it('should add assets', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true },
const assetId = newUuid();
const memory = factory.memory();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.memory.get.mockResolvedValue(memory);
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
{ id: assetId, success: true },
]);
expect(mocks.memory.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
expect(mocks.memory.addAssetIds).toHaveBeenCalledWith(memory.id, [assetId]);
});
});
describe('removeAssets', () => {
it('should require memory access', async () => {
await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
await expect(sut.removeAssets(factory.auth(), 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled();
});
it('should skip assets not in the memory', async () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
await expect(sut.removeAssets(factory.auth(), 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'not_found', id: 'not-found', success: false },
]);
expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled();
});
@ -195,9 +245,11 @@ describe(MemoryService.name, () => {
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
await expect(sut.removeAssets(factory.auth(), 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true },
]);
expect(mocks.memory.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
});
});

View file

@ -1,6 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { JsonObject } from 'src/db';
import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@ -97,7 +96,7 @@ export class MemoryService extends BaseService {
{
ownerId: auth.user.id,
type: dto.type,
data: dto.data as unknown as JsonObject,
data: dto.data,
isSaved: dto.isSaved,
memoryAt: dto.memoryAt,
seenAt: dto.seenAt,

View file

@ -1,34 +0,0 @@
import { MemoryType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
export const memoryStub = {
empty: {
id: 'memoryEmpty',
createdAt: new Date(),
updatedAt: new Date(),
memoryAt: new Date(2024),
ownerId: userStub.admin.id,
owner: userStub.admin,
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
isSaved: false,
assets: [],
deletedAt: null,
seenAt: null,
} as unknown as any,
memory1: {
id: 'memory1',
createdAt: new Date(),
updatedAt: new Date(),
memoryAt: new Date(2024),
ownerId: userStub.admin.id,
owner: userStub.admin,
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
isSaved: false,
assets: [assetStub.image1],
deletedAt: null,
seenAt: null,
} as unknown as any,
};

View file

@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto';
import { AuthUser, User } from 'src/database';
import { ActivityItem } from 'src/types';
import { Asset, AuthUser, User } from 'src/database';
import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetStatus, AssetType, MemoryType } from 'src/enum';
import { ActivityItem, MemoryItem } from 'src/types';
export const newUuid = () => randomUUID() as string;
export const newUuids = () =>
@ -9,6 +11,7 @@ export const newUuids = () =>
.map(() => newUuid());
export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7';
export const newSha1 = () => Buffer.from('this is a fake hash');
const authUser = (authUser: Partial<AuthUser>) => ({
id: newUuid(),
@ -35,6 +38,38 @@ export const factory = {
}),
authUser,
user,
asset: (asset: Partial<Asset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUpdateId(),
status: AssetStatus.ACTIVE,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
...asset,
}),
activity: (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
@ -51,4 +86,21 @@ export const factory = {
...activity,
};
},
memory: (memory: Partial<MemoryItem> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
}),
};