From ce74f765b1b0ce1ff1cf5db95699afe2131309ec Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Mar 2025 16:03:34 -0500 Subject: [PATCH] refactor: memory stub (#16704) --- server/src/database.ts | 34 +++- server/src/db.d.ts | 7 +- server/src/services/memory.service.spec.ts | 200 +++++++++++++-------- server/src/services/memory.service.ts | 3 +- server/test/fixtures/memory.stub.ts | 34 ---- server/test/small.factory.ts | 56 +++++- 6 files changed, 218 insertions(+), 116 deletions(-) delete mode 100644 server/test/fixtures/memory.stub.ts diff --git a/server/src/database.ts b/server/src/database.ts index 92f6c5f702..371448efe4 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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; + 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 | null; + type: AssetType; +}; + export type AuthSharedLink = { id: string; expiresAt: Date | null; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4617ddf707..a27faac9b6 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -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 = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -231,7 +232,7 @@ export interface Libraries { export interface Memories { createdAt: Generated; - data: Json; + data: OnThisDayData; deletedAt: Timestamp | null; hideAt: Timestamp | null; id: Generated; @@ -240,7 +241,7 @@ export interface Memories { ownerId: string; seenAt: Timestamp | null; showAt: Timestamp | null; - type: string; + type: MemoryType; updatedAt: Generated; updateId: Generated; } diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index e3d85133ac..6af300c164 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -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']); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 8a46b289c3..28c90f6576 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -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, diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts deleted file mode 100644 index 5b3d5635c4..0000000000 --- a/server/test/fixtures/memory.stub.ts +++ /dev/null @@ -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, -}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 5901caa88f..c3455660ce 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -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) => ({ id: newUuid(), @@ -35,6 +38,38 @@ export const factory = { }), authUser, user, + asset: (asset: Partial = {}) => ({ + 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 = {}) => { const userId = activity.userId || newUuid(); return { @@ -51,4 +86,21 @@ export const factory = { ...activity, }; }, + memory: (memory: Partial = {}) => ({ + 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, + }), };