Merge remote-tracking branch 'origin/main' into chore/pnpm_alt

This commit is contained in:
Min Idzelis
2025-06-28 03:50:28 +00:00
332 changed files with 53395 additions and 3611 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ WORKDIR /workspaces/immich
RUN rm -rf /tmp/build
FROM dev-container-server AS dev-container-mobile
USER root
# Enable multiarch for arm64 if necessary
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
sudo dpkg --add-architecture amd64 && \
+18933
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.15.31",
"@types/node": "^22.15.32",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
+2 -1
View File
@@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { AuthService } from 'src/services/auth.service';
import { getKyselyConfig } from 'src/utils/database';
@@ -111,7 +112,7 @@ class SqlGenerator {
data.push(...(await this.runTargets(instance, `${Repository.name}`)));
// nested repositories
if (Repository.name === AccessRepository.name) {
if (Repository.name === AccessRepository.name || Repository.name === SyncRepository.name) {
for (const key of Object.keys(instance)) {
const subInstance = (instance as any)[key];
data.push(...(await this.runTargets(subInstance, `${Repository.name}.${key}`)));
@@ -2,6 +2,7 @@ import { AuthController } from 'src/controllers/auth.controller';
import { LoginResponseDto } from 'src/dtos/auth.dto';
import { AuthService } from 'src/services/auth.service';
import request from 'supertest';
import { mediumFactory } from 'test/medium.factory';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
@@ -132,6 +133,50 @@ describe(AuthController.name, () => {
expect(status).toEqual(201);
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything());
});
it('should auth cookies on a secure connection', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { status, body, headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
expect(body).toEqual(loginResponse);
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${loginResponse.accessToken}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/logout', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/logout');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /auth/change-password', () => {
@@ -0,0 +1,132 @@
import { MemoryController } from 'src/controllers/memory.controller';
import { MemoryService } from 'src/services/memory.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(MemoryController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(MemoryService);
beforeAll(async () => {
ctx = await controllerSetup(MemoryController, [{ provide: MemoryService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /memories', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/memories');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /memories', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/memories');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should validate data when type is on this day', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/memories')
.send({
type: 'on_this_day',
data: {},
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
});
});
describe('GET /memories/statistics', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/memories/statistics');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /memories/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/memories/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
});
describe('PUT /memories/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
});
describe('DELETE /memories/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /memories/:id/assets', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
});
describe('DELETE /memories/:id/assets', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
});
});
@@ -60,6 +60,29 @@ describe(PersonController.name, () => {
});
});
describe('DELETE /people', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/people');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require uuids in the body', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/people')
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
it('should respond with 204', async () => {
const { status } = await request(ctx.getHttpServer())
.delete(`/people`)
.send({ ids: [factory.uuid()] });
expect(status).toBe(204);
expect(service.deleteAll).toHaveBeenCalled();
});
});
describe('GET /people/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
@@ -156,6 +179,25 @@ describe(PersonController.name, () => {
});
});
describe('DELETE /people/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
it('should respond with 204', async () => {
const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
expect(status).toBe(204);
expect(service.delete).toHaveBeenCalled();
});
});
describe('POST /people/:id/merge', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`);
+29 -2
View File
@@ -1,7 +1,20 @@
import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Next,
Param,
Post,
Put,
Query,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
@@ -49,6 +62,13 @@ export class PersonController {
return this.service.updateAll(auth, dto);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PERSON_DELETE })
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.PERSON_READ })
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
@@ -65,6 +85,13 @@ export class PersonController {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PERSON_DELETE })
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Get(':id/statistics')
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
@@ -0,0 +1,84 @@
import { SyncController } from 'src/controllers/sync.controller';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { SyncService } from 'src/services/sync.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SyncController.name, () => {
let ctx: ControllerContext;
const syncService = mockBaseService(SyncService);
const errorService = { handleError: vi.fn() };
beforeAll(async () => {
ctx = await controllerSetup(SyncController, [
{ provide: SyncService, useValue: syncService },
{ provide: GlobalExceptionFilter, useValue: errorService },
]);
return () => ctx.close();
});
beforeEach(() => {
syncService.resetAllMocks();
errorService.handleError.mockReset();
ctx.reset();
});
describe('POST /sync/stream', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/sync/stream');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require sync request type enums', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/sync/stream')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /sync/ack', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/sync/ack');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /sync/ack', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/sync/ack');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should not allow more than 1,000 entries', async () => {
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['acks must contain no more than 1000 elements']));
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /sync/ack', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/sync/ack');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require sync response type enums', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/sync/ack')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
});
+16 -23
View File
@@ -13,7 +13,7 @@ import {
UserAvatarColor,
UserStatus,
} from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { UserMetadataItem } from 'src/types';
export type AuthUser = {
id: string;
@@ -95,7 +95,7 @@ export type Memory = {
showAt: Date | null;
hideAt: Date | null;
type: MemoryType;
data: OnThisDayData;
data: object;
ownerId: string;
isSaved: boolean;
assets: MapAsset[];
@@ -340,27 +340,21 @@ export const columns = {
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
syncAsset: [
'id',
'ownerId',
'originalFileName',
'thumbhash',
'checksum',
'fileCreatedAt',
'fileModifiedAt',
'localDateTime',
'type',
'deletedAt',
'isFavorite',
'visibility',
'updateId',
'duration',
],
syncAlbumUser: [
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
'assets.id',
'assets.ownerId',
'assets.originalFileName',
'assets.thumbhash',
'assets.checksum',
'assets.fileCreatedAt',
'assets.fileModifiedAt',
'assets.localDateTime',
'assets.type',
'assets.deletedAt',
'assets.isFavorite',
'assets.visibility',
'assets.duration',
],
syncAlbumUser: ['album_users.albumsId as albumId', 'album_users.usersId as userId', 'album_users.role'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'exif.assetId',
@@ -388,7 +382,6 @@ export const columns = {
'exif.profileDescription',
'exif.rating',
'exif.fps',
'exif.updateId',
],
exif: [
'exif.assetId',
+18 -8
View File
@@ -19,8 +19,11 @@ import {
SourceType,
SyncEntityType,
} from 'src/enum';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table';
import { UserTable } from 'src/schema/tables/user.table';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { UserMetadataItem } from 'src/types';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
@@ -92,6 +95,15 @@ export interface AlbumsAssetsAssets {
albumsId: string;
assetsId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface AlbumAssetsAudit {
deletedAt: Generated<Timestamp>;
id: Generated<string>;
albumId: string;
assetId: string;
}
export interface AlbumsSharedUsersUsers {
@@ -269,7 +281,7 @@ export interface Libraries {
export interface Memories {
createdAt: Generated<Timestamp>;
data: OnThisDayData;
data: object;
deletedAt: Timestamp | null;
hideAt: Timestamp | null;
id: Generated<string>;
@@ -298,11 +310,6 @@ export interface Notifications {
readAt: Timestamp | null;
}
export interface MemoriesAssetsAssets {
assetsId: string;
memoriesId: string;
}
export interface Migrations {
id: Generated<number>;
name: string;
@@ -487,6 +494,7 @@ export interface DB {
albums: Albums;
albums_audit: AlbumsAudit;
albums_assets_assets: AlbumsAssetsAssets;
album_assets_audit: AlbumAssetsAudit;
albums_shared_users_users: AlbumsSharedUsersUsers;
album_users_audit: AlbumUsersAudit;
api_keys: ApiKeys;
@@ -502,7 +510,9 @@ export interface DB {
geodata_places: GeodataPlaces;
libraries: Libraries;
memories: Memories;
memories_assets_assets: MemoriesAssetsAssets;
memories_audit: MemoryAuditTable;
memories_assets_assets: MemoryAssetTable;
memory_assets_audit: MemoryAssetAuditTable;
migrations: Migrations;
notifications: Notifications;
move_history: MoveHistory;
+92 -19
View File
@@ -1,7 +1,16 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
import { ArrayMaxSize, IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
import {
AlbumUserRole,
AssetOrder,
AssetType,
AssetVisibility,
MemoryType,
SyncEntityType,
SyncRequestType,
} from 'src/enum';
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
export class AssetFullSyncDto {
@@ -34,6 +43,15 @@ export class AssetDeltaSyncResponseDto {
deleted!: string[];
}
export const extraSyncModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
return (object: Function) => {
extraSyncModels.push(object);
};
};
@ExtraModel()
export class SyncUserV1 {
id!: string;
name!: string;
@@ -41,21 +59,25 @@ export class SyncUserV1 {
deletedAt!: Date | null;
}
@ExtraModel()
export class SyncUserDeleteV1 {
userId!: string;
}
@ExtraModel()
export class SyncPartnerV1 {
sharedById!: string;
sharedWithId!: string;
inTimeline!: boolean;
}
@ExtraModel()
export class SyncPartnerDeleteV1 {
sharedById!: string;
sharedWithId!: string;
}
@ExtraModel()
export class SyncAssetV1 {
id!: string;
ownerId!: string;
@@ -74,10 +96,12 @@ export class SyncAssetV1 {
visibility!: AssetVisibility;
}
@ExtraModel()
export class SyncAssetDeleteV1 {
assetId!: string;
}
@ExtraModel()
export class SyncAssetExifV1 {
assetId!: string;
description!: string | null;
@@ -116,15 +140,18 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAlbumDeleteV1 {
albumId!: string;
}
@ExtraModel()
export class SyncAlbumUserDeleteV1 {
albumId!: string;
userId!: string;
}
@ExtraModel()
export class SyncAlbumUserV1 {
albumId!: string;
userId!: string;
@@ -132,6 +159,7 @@ export class SyncAlbumUserV1 {
role!: AlbumUserRole;
}
@ExtraModel()
export class SyncAlbumV1 {
id!: string;
ownerId!: string;
@@ -145,6 +173,55 @@ export class SyncAlbumV1 {
order!: AssetOrder;
}
@ExtraModel()
export class SyncAlbumToAssetV1 {
albumId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncAlbumToAssetDeleteV1 {
albumId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncMemoryV1 {
id!: string;
createdAt!: Date;
updatedAt!: Date;
deletedAt!: Date | null;
ownerId!: string;
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
type!: MemoryType;
data!: object;
isSaved!: boolean;
memoryAt!: Date;
seenAt!: Date | null;
showAt!: Date | null;
hideAt!: Date | null;
}
@ExtraModel()
export class SyncMemoryDeleteV1 {
memoryId!: string;
}
@ExtraModel()
export class SyncMemoryAssetV1 {
memoryId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncMemoryAssetDeleteV1 {
memoryId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncAckV1 {}
export type SyncItem = {
[SyncEntityType.UserV1]: SyncUserV1;
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
@@ -163,25 +240,20 @@ export type SyncItem = {
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.SyncAckV1]: object;
[SyncEntityType.AlbumAssetV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetDeleteV1]: SyncAlbumToAssetDeleteV1;
[SyncEntityType.MemoryV1]: SyncMemoryV1;
[SyncEntityType.MemoryDeleteV1]: SyncMemoryDeleteV1;
[SyncEntityType.MemoryToAssetV1]: SyncMemoryAssetV1;
[SyncEntityType.MemoryToAssetDeleteV1]: SyncMemoryAssetDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
};
const responseDtos = [
SyncUserV1,
SyncUserDeleteV1,
SyncPartnerV1,
SyncPartnerDeleteV1,
SyncAssetV1,
SyncAssetDeleteV1,
SyncAssetExifV1,
SyncAlbumV1,
SyncAlbumDeleteV1,
SyncAlbumUserV1,
SyncAlbumUserDeleteV1,
];
export const extraSyncModels = responseDtos;
export class SyncStreamDto {
@IsEnum(SyncRequestType, { each: true })
@ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true })
@@ -195,6 +267,7 @@ export class SyncAckDto {
}
export class SyncAckSetDto {
@ArrayMaxSize(1000)
@IsString({ each: true })
acks!: string[];
}
+24 -3
View File
@@ -581,19 +581,24 @@ export enum SyncRequestType {
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
}
export enum SyncEntityType {
UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
PartnerAssetV1 = 'PartnerAssetV1',
PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1',
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
@@ -602,10 +607,26 @@ export enum SyncEntityType {
AlbumV1 = 'AlbumV1',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
AlbumAssetV1 = 'AlbumAssetV1',
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetExifV1 = 'AlbumAssetExifV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
AlbumToAssetV1 = 'AlbumToAssetV1',
AlbumToAssetDeleteV1 = 'AlbumToAssetDeleteV1',
AlbumToAssetBackfillV1 = 'AlbumToAssetBackfillV1',
MemoryV1 = 'MemoryV1',
MemoryDeleteV1 = 'MemoryDeleteV1',
MemoryToAssetV1 = 'MemoryToAssetV1',
MemoryToAssetDeleteV1 = 'MemoryToAssetDeleteV1',
SyncAckV1 = 'SyncAckV1',
}
@@ -394,7 +394,6 @@ where
"asset_job_status"."metadataExtractedAt" is null
or "asset_job_status"."assetId" is null
)
and "assets"."visibility" != $1
and "assets"."deletedAt" is null
-- AssetJobRepository.getForStorageTemplateJob
+9
View File
@@ -328,3 +328,12 @@ set
"deletedAt" = $1
where
"asset_faces"."id" = $2
-- PersonRepository.getForPeopleDelete
select
"id",
"thumbnailPath"
from
"person"
where
"id" in ($1)
@@ -0,0 +1,15 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SyncCheckpointRepository.getAll
select
"type",
"ack"
from
"session_sync_checkpoints"
where
"sessionId" = $1
-- SyncCheckpointRepository.deleteAll
delete from "session_sync_checkpoints"
where
"sessionId" = $1
+505 -244
View File
@@ -1,172 +1,330 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SyncRepository.getCheckpoints
-- SyncRepository.album.getCreatedAfter
select
"type",
"ack"
"albumsId" as "id",
"createId"
from
"session_sync_checkpoints"
"albums_shared_users_users"
where
"sessionId" = $1
-- SyncRepository.deleteCheckpoints
delete from "session_sync_checkpoints"
where
"sessionId" = $1
-- SyncRepository.getUserUpserts
select
"id",
"name",
"email",
"deletedAt",
"updateId"
from
"users"
where
"updatedAt" < now() - interval '1 millisecond'
"usersId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"updateId" asc
"createId" asc
-- SyncRepository.getUserDeletes
-- SyncRepository.album.getDeletes
select
"id",
"userId"
"albumId"
from
"users_audit"
"albums_audit"
where
"deletedAt" < now() - interval '1 millisecond'
"userId" = $1
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getPartnerUpserts
select
"sharedById",
"sharedWithId",
"inTimeline",
"updateId"
-- SyncRepository.album.getUpserts
select distinct
on ("albums"."id", "albums"."updateId") "albums"."id",
"albums"."ownerId",
"albums"."albumName" as "name",
"albums"."description",
"albums"."createdAt",
"albums"."updatedAt",
"albums"."albumThumbnailAssetId" as "thumbnailAssetId",
"albums"."isActivityEnabled",
"albums"."order",
"albums"."updateId"
from
"partners"
"albums"
left join "albums_shared_users_users" as "album_users" on "albums"."id" = "album_users"."albumsId"
where
(
"sharedById" = $1
or "sharedWithId" = $2
"albums"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
"albums"."updateId" asc
-- SyncRepository.getPartnerDeletes
-- SyncRepository.albumAsset.getBackfill
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
where
"album_assets"."albumsId" = $1
and "assets"."updatedAt" < now() - interval '1 millisecond'
and "assets"."updateId" <= $2
and "assets"."updateId" >= $3
order by
"assets"."updateId" asc
-- SyncRepository.albumAsset.getUpserts
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"assets"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"assets"."updateId" asc
-- SyncRepository.albumAssetExif.getBackfill
select
"exif"."assetId",
"exif"."description",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"exif"."fileSizeInByte",
"exif"."orientation",
"exif"."dateTimeOriginal",
"exif"."modifyDate",
"exif"."timeZone",
"exif"."latitude",
"exif"."longitude",
"exif"."projectionType",
"exif"."city",
"exif"."state",
"exif"."country",
"exif"."make",
"exif"."model",
"exif"."lensModel",
"exif"."fNumber",
"exif"."focalLength",
"exif"."iso",
"exif"."exposureTime",
"exif"."profileDescription",
"exif"."rating",
"exif"."fps",
"exif"."updateId"
from
"exif"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId"
where
"album_assets"."albumsId" = $1
and "exif"."updatedAt" < now() - interval '1 millisecond'
and "exif"."updateId" <= $2
and "exif"."updateId" >= $3
order by
"exif"."updateId" asc
-- SyncRepository.albumAssetExif.getUpserts
select
"exif"."assetId",
"exif"."description",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"exif"."fileSizeInByte",
"exif"."orientation",
"exif"."dateTimeOriginal",
"exif"."modifyDate",
"exif"."timeZone",
"exif"."latitude",
"exif"."longitude",
"exif"."projectionType",
"exif"."city",
"exif"."state",
"exif"."country",
"exif"."make",
"exif"."model",
"exif"."lensModel",
"exif"."fNumber",
"exif"."focalLength",
"exif"."iso",
"exif"."exposureTime",
"exif"."profileDescription",
"exif"."rating",
"exif"."fps",
"exif"."updateId"
from
"exif"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"exif"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"exif"."updateId" asc
-- SyncRepository.albumToAsset.getBackfill
select
"album_assets"."assetsId" as "assetId",
"album_assets"."albumsId" as "albumId",
"album_assets"."updateId"
from
"albums_assets_assets" as "album_assets"
where
"album_assets"."albumsId" = $1
and "album_assets"."updatedAt" < now() - interval '1 millisecond'
and "album_assets"."updateId" <= $2
and "album_assets"."updateId" >= $3
order by
"album_assets"."updateId" asc
-- SyncRepository.albumToAsset.getDeletes
select
"id",
"sharedById",
"sharedWithId"
"assetId",
"albumId"
from
"partners_audit"
"album_assets_audit"
where
(
"sharedById" = $1
or "sharedWithId" = $2
"albumId" in (
select
"id"
from
"albums"
where
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getAssetUpserts
-- SyncRepository.albumToAsset.getUpserts
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
"album_assets"."assetsId" as "assetId",
"album_assets"."albumsId" as "albumId",
"album_assets"."updateId"
from
"assets"
"albums_assets_assets" as "album_assets"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
"album_assets"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"updateId" asc
"album_assets"."updateId" asc
-- SyncRepository.getPartnerBackfill
-- SyncRepository.albumUser.getBackfill
select
"sharedById",
"createId"
"album_users"."albumsId" as "albumId",
"album_users"."usersId" as "userId",
"album_users"."role",
"album_users"."updateId"
from
"partners"
"albums_shared_users_users" as "album_users"
where
"sharedWithId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"partners"."createId" asc
-- SyncRepository.getPartnerAssetsBackfill
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
from
"assets"
where
"ownerId" = $1
"albumsId" = $1
and "updatedAt" < now() - interval '1 millisecond'
and "updateId" <= $2
and "updateId" >= $3
order by
"updateId" asc
-- SyncRepository.getPartnerAssetsUpserts
-- SyncRepository.albumUser.getDeletes
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
"userId",
"albumId"
from
"assets"
"album_users_audit"
where
"ownerId" in (
"albumId" in (
select
"sharedById"
"id"
from
"partners"
"albums"
where
"sharedWithId" = $1
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
and "updatedAt" < now() - interval '1 millisecond'
and "deletedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
"id" asc
-- SyncRepository.getAssetDeletes
-- SyncRepository.albumUser.getUpserts
select
"album_users"."albumsId" as "albumId",
"album_users"."usersId" as "userId",
"album_users"."role",
"album_users"."updateId"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."updatedAt" < now() - interval '1 millisecond'
and "album_users"."albumsId" in (
select
"id"
from
"albums"
where
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
order by
"album_users"."updateId" asc
-- SyncRepository.asset.getDeletes
select
"id",
"assetId"
@@ -178,26 +336,31 @@ where
order by
"id" asc
-- SyncRepository.getPartnerAssetDeletes
-- SyncRepository.asset.getUpserts
select
"id",
"assetId"
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets_audit"
"assets"
where
"ownerId" in (
select
"sharedById"
from
"partners"
where
"sharedWithId" = $1
)
and "deletedAt" < now() - interval '1 millisecond'
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
order by
"id" asc
"updateId" asc
-- SyncRepository.getAssetExifsUpserts
-- SyncRepository.assetExif.getUpserts
select
"exif"."assetId",
"exif"."description",
@@ -240,7 +403,204 @@ where
order by
"updateId" asc
-- SyncRepository.getPartnerAssetExifsBackfill
-- SyncRepository.memory.getDeletes
select
"id",
"memoryId"
from
"memories_audit"
where
"userId" = $1
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.memory.getUpserts
select
"id",
"createdAt",
"updatedAt",
"deletedAt",
"ownerId",
"type",
"data",
"isSaved",
"memoryAt",
"seenAt",
"showAt",
"hideAt",
"updateId"
from
"memories"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.memoryToAsset.getDeletes
select
"id",
"memoryId",
"assetId"
from
"memory_assets_audit"
where
"memoryId" in (
select
"id"
from
"memories"
where
"ownerId" = $1
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.memoryToAsset.getUpserts
select
"memoriesId" as "memoryId",
"assetsId" as "assetId",
"updateId"
from
"memories_assets_assets"
where
"memoriesId" in (
select
"id"
from
"memories"
where
"ownerId" = $1
)
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.partner.getCreatedAfter
select
"sharedById",
"createId"
from
"partners"
where
"sharedWithId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"partners"."createId" asc
-- SyncRepository.partner.getDeletes
select
"id",
"sharedById",
"sharedWithId"
from
"partners_audit"
where
(
"sharedById" = $1
or "sharedWithId" = $2
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.partner.getUpserts
select
"sharedById",
"sharedWithId",
"inTimeline",
"updateId"
from
"partners"
where
(
"sharedById" = $1
or "sharedWithId" = $2
)
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.partnerAsset.getBackfill
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
and "updateId" <= $2
and "updateId" >= $3
order by
"updateId" asc
-- SyncRepository.partnerAsset.getDeletes
select
"id",
"assetId"
from
"assets_audit"
where
"ownerId" in (
select
"sharedById"
from
"partners"
where
"sharedWithId" = $1
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.partnerAsset.getUpserts
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
where
"ownerId" in (
select
"sharedById"
from
"partners"
where
"sharedWithId" = $1
)
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.partnerAssetExif.getBackfill
select
"exif"."assetId",
"exif"."description",
@@ -279,7 +639,7 @@ where
order by
"exif"."updateId" asc
-- SyncRepository.getPartnerAssetExifsUpserts
-- SyncRepository.partnerAssetExif.getUpserts
select
"exif"."assetId",
"exif"."description",
@@ -329,126 +689,27 @@ where
order by
"updateId" asc
-- SyncRepository.getAlbumDeletes
-- SyncRepository.user.getDeletes
select
"id",
"albumId"
"userId"
from
"albums_audit"
"users_audit"
where
"userId" = $1
and "deletedAt" < now() - interval '1 millisecond'
"deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getAlbumUpserts
select distinct
on ("albums"."id", "albums"."updateId") "albums"."id",
"albums"."ownerId",
"albums"."albumName" as "name",
"albums"."description",
"albums"."createdAt",
"albums"."updatedAt",
"albums"."albumThumbnailAssetId" as "thumbnailAssetId",
"albums"."isActivityEnabled",
"albums"."order",
"albums"."updateId"
from
"albums"
left join "albums_shared_users_users" as "album_users" on "albums"."id" = "album_users"."albumsId"
where
"albums"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"albums"."updateId" asc
-- SyncRepository.getAlbumUserDeletes
-- SyncRepository.user.getUpserts
select
"id",
"userId",
"albumId"
"name",
"email",
"deletedAt",
"updateId"
from
"album_users_audit"
"users"
where
"albumId" in (
select
"id"
from
"albums"
where
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getAlbumBackfill
select
"albumsId" as "id",
"createId"
from
"albums_shared_users_users"
where
"usersId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"createId" asc
-- SyncRepository.getAlbumUsersBackfill
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
from
"albums_shared_users_users"
where
"albumsId" = $1
and "updatedAt" < now() - interval '1 millisecond'
and "updateId" <= $2
and "updateId" >= $3
"updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.getAlbumUserUpserts
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
from
"albums_shared_users_users"
where
"albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond'
and "albums_shared_users_users"."albumsId" in (
select
"id"
from
"albums"
where
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
order by
"albums_shared_users_users"."updateId" asc
+10
View File
@@ -97,6 +97,16 @@ where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getForChangePassword
select
"users"."id",
"users"."password"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getByEmail
select
"id",
@@ -273,8 +273,7 @@ export class AssetJobRepository {
.leftJoin('asset_job_status', 'asset_job_status.assetId', 'assets.id')
.where((eb) =>
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN),
),
)
.where('assets.deletedAt', 'is', null)
.stream();
+2
View File
@@ -34,6 +34,7 @@ import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
@@ -81,6 +82,7 @@ export const repositories = [
StackRepository,
StorageRepository,
SyncRepository,
SyncCheckpointRepository,
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
+11 -1
View File
@@ -3,7 +3,7 @@ import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } fr
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
@@ -102,6 +102,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async delete(ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
@@ -517,4 +518,13 @@ export class PersonRepository {
await sql`REINDEX TABLE face_search`.execute(this.db);
}
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
getForPeopleDelete(ids: string[]) {
if (ids.length === 0) {
return Promise.resolve([]);
}
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, SessionSyncCheckpoints } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
@Injectable()
export class SyncCheckpointRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getAll(sessionId: string) {
return this.db
.selectFrom('session_sync_checkpoints')
.select(['type', 'ack'])
.where('sessionId', '=', sessionId)
.execute();
}
upsertAll(items: Insertable<SessionSyncCheckpoints>[]) {
return this.db
.insertInto('session_sync_checkpoints')
.values(items)
.onConflict((oc) =>
oc.columns(['sessionId', 'type']).doUpdateSet((eb) => ({
ack: eb.ref('excluded.ack'),
})),
)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
deleteAll(sessionId: string, types?: SyncEntityType[]) {
return this.db
.deleteFrom('session_sync_checkpoints')
.where('sessionId', '=', sessionId)
.$if(!!types, (qb) => qb.where('type', 'in', types!))
.execute();
}
}
+447 -222
View File
@@ -1,199 +1,104 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, SelectQueryBuilder, sql } from 'kysely';
import { Kysely, SelectQueryBuilder, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, SessionSyncCheckpoints } from 'src/db';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
import { SyncAck } from 'src/types';
type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit';
type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users';
type AuditTables =
| 'users_audit'
| 'partners_audit'
| 'assets_audit'
| 'albums_audit'
| 'album_users_audit'
| 'album_assets_audit'
| 'memories_audit'
| 'memory_assets_audit';
type UpsertTables =
| 'users'
| 'partners'
| 'assets'
| 'exif'
| 'albums'
| 'albums_shared_users_users'
| 'memories'
| 'memories_assets_assets';
@Injectable()
export class SyncRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
album: AlbumSync;
albumAsset: AlbumAssetSync;
albumAssetExif: AlbumAssetExifSync;
albumToAsset: AlbumToAssetSync;
albumUser: AlbumUserSync;
asset: AssetSync;
assetExif: AssetExifSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
partner: PartnerSync;
partnerAsset: PartnerAssetsSync;
partnerAssetExif: PartnerAssetExifsSync;
user: UserSync;
@GenerateSql({ params: [DummyValue.UUID] })
getCheckpoints(sessionId: string) {
return this.db
.selectFrom('session_sync_checkpoints')
.select(['type', 'ack'])
.where('sessionId', '=', sessionId)
.execute();
constructor(@InjectKysely() private db: Kysely<DB>) {
this.album = new AlbumSync(this.db);
this.albumAsset = new AlbumAssetSync(this.db);
this.albumAssetExif = new AlbumAssetExifSync(this.db);
this.albumToAsset = new AlbumToAssetSync(this.db);
this.albumUser = new AlbumUserSync(this.db);
this.asset = new AssetSync(this.db);
this.assetExif = new AssetExifSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
this.partner = new PartnerSync(this.db);
this.partnerAsset = new PartnerAssetsSync(this.db);
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
this.user = new UserSync(this.db);
}
}
class BaseSync {
constructor(protected db: Kysely<DB>) {}
protected auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(
qb: SelectQueryBuilder<DB, T, D>,
ack?: SyncAck,
) {
const builder = qb as SelectQueryBuilder<DB, AuditTables, D>;
return builder
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
.orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>;
}
upsertCheckpoints(items: Insertable<SessionSyncCheckpoints>[]) {
return this.db
.insertInto('session_sync_checkpoints')
.values(items)
.onConflict((oc) =>
oc.columns(['sessionId', 'type']).doUpdateSet((eb) => ({
ack: eb.ref('excluded.ack'),
})),
)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
deleteCheckpoints(sessionId: string, types?: SyncEntityType[]) {
return this.db
.deleteFrom('session_sync_checkpoints')
.where('sessionId', '=', sessionId)
.$if(!!types, (qb) => qb.where('type', 'in', types!))
.execute();
}
@GenerateSql({ params: [], stream: true })
getUserUpserts(ack?: SyncAck) {
return this.db
.selectFrom('users')
.select(['id', 'name', 'email', 'deletedAt', 'updateId'])
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [], stream: true })
getUserDeletes(ack?: SyncAck) {
return this.db
.selectFrom('users_audit')
.select(['id', 'userId'])
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getPartnerUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('partners')
.select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId'])
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getPartnerDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('partners_audit')
.select(['id', 'sharedById', 'sharedWithId'])
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAssetUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
protected upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>(
qb: SelectQueryBuilder<DB, T, D>,
ack?: SyncAck,
) {
const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>;
return builder
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
.orderBy('updateId', 'asc') as SelectQueryBuilder<DB, T, D>;
}
}
class AlbumSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getPartnerBackfill(userId: string, afterCreateId?: string) {
getCreatedAfter(userId: string, afterCreateId?: string) {
return this.db
.selectFrom('partners')
.select(['sharedById', 'createId'])
.where('sharedWithId', '=', userId)
.selectFrom('albums_shared_users_users')
.select(['albumsId as id', 'createId'])
.where('usersId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createdAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.orderBy('partners.createId', 'asc')
.orderBy('createId', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getPartnerAssetsBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.where('ownerId', '=', partnerId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getPartnerAssetsUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAssetDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets_audit')
.select(['id', 'assetId'])
.where('ownerId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getPartnerAssetDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets_audit')
.select(['id', 'assetId'])
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAssetExifsUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getPartnerAssetExifsBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('assets.ownerId', '=', partnerId)
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('exif.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!))
.orderBy('exif.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.where('assetId', 'in', (eb) =>
eb
.selectFrom('assets')
.select('id')
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
),
)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumDeletes(userId: string, ack?: SyncAck) {
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_audit')
.select(['id', 'albumId'])
@@ -203,7 +108,7 @@ export class SyncRepository {
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUpserts(userId: string, ack?: SyncAck) {
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums')
.distinctOn(['albums.id', 'albums.updateId'])
@@ -226,9 +131,147 @@ export class SyncRepository {
])
.stream();
}
}
class AlbumAssetSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('assets')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select(columns.syncAsset)
.select('assets.updateId')
.where('album_assets.albumsId', '=', albumId)
.where('assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('assets.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('assets.updateId', '>=', afterUpdateId!))
.orderBy('assets.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserDeletes(userId: string, ack?: SyncAck) {
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select(columns.syncAsset)
.select('assets.updateId')
.where('assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('assets.updateId', '>', ack!.updateId))
.orderBy('assets.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
}
class AlbumAssetExifSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('exif')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('album_assets.albumsId', '=', albumId)
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('exif.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!))
.orderBy('exif.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('exif.updateId', '>', ack!.updateId))
.orderBy('exif.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
}
class AlbumToAssetSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_assets_assets as album_assets')
.select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId'])
.where('album_assets.albumsId', '=', albumId)
.where('album_assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('album_assets.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('album_assets.updateId', '>=', afterUpdateId!))
.orderBy('album_assets.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('album_assets_audit')
.select(['id', 'assetId', 'albumId'])
.where((eb) =>
eb(
'albumId',
'in',
eb
.selectFrom('albums')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('albums_shared_users_users as albumUsers')
.select(['albumUsers.albumsId as id'])
.where('albumUsers.usersId', '=', userId),
),
),
),
)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_assets_assets as album_assets')
.select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId'])
.where('album_assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('album_assets.updateId', '>', ack!.updateId))
.orderBy('album_assets.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
}
class AlbumUserSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_shared_users_users as album_users')
.select(columns.syncAlbumUser)
.select('album_users.updateId')
.where('albumsId', '=', albumId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('album_users_audit')
.select(['id', 'userId', 'albumId'])
@@ -254,42 +297,18 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getAlbumBackfill(userId: string, afterCreateId?: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(['albumsId as id', 'createId'])
.where('usersId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createdAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.orderBy('createId', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(columns.syncAlbumUser)
.where('albumsId', '=', albumId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserUpserts(userId: string, ack?: SyncAck) {
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_shared_users_users')
.selectFrom('albums_shared_users_users as album_users')
.select(columns.syncAlbumUser)
.where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId))
.orderBy('albums_shared_users_users.updateId', 'asc')
.select('album_users.updateId')
.where('album_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('album_users.updateId', '>', ack!.updateId))
.orderBy('album_users.updateId', 'asc')
.where((eb) =>
eb(
'albums_shared_users_users.albumsId',
'album_users.albumsId',
'in',
eb
.selectFrom('albums')
@@ -307,23 +326,229 @@ export class SyncRepository {
)
.stream();
}
}
private auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
const builder = qb as SelectQueryBuilder<DB, AuditTables, D>;
return builder
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
.orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>;
class AssetSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets_audit')
.select(['id', 'assetId'])
.where('ownerId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
private upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>(
qb: SelectQueryBuilder<DB, T, D>,
ack?: SyncAck,
) {
const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>;
return builder
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
.orderBy('updateId', 'asc') as SelectQueryBuilder<DB, T, D>;
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class AssetExifSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class MemorySync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories_audit')
.select(['id', 'memoryId'])
.where('userId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories')
.select([
'id',
'createdAt',
'updatedAt',
'deletedAt',
'ownerId',
'type',
'data',
'isSaved',
'memoryAt',
'seenAt',
'showAt',
'hideAt',
])
.select('updateId')
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class MemoryToAssetSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memory_assets_audit')
.select(['id', 'memoryId', 'assetId'])
.where('memoryId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId))
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories_assets_assets')
.select(['memoriesId as memoryId', 'assetsId as assetId'])
.select('updateId')
.where('memoriesId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class PartnerSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getCreatedAfter(userId: string, afterCreateId?: string) {
return this.db
.selectFrom('partners')
.select(['sharedById', 'createId'])
.where('sharedWithId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createdAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.orderBy('partners.createId', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('partners_audit')
.select(['id', 'sharedById', 'sharedWithId'])
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('partners')
.select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId'])
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class PartnerAssetsSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', '=', partnerId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets_audit')
.select(['id', 'assetId'])
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class PartnerAssetExifsSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(partnerId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('assets.ownerId', '=', partnerId)
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('exif.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!))
.orderBy('exif.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('assetId', 'in', (eb) =>
eb
.selectFrom('assets')
.select('id')
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
),
)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class UserSync extends BaseSync {
@GenerateSql({ params: [], stream: true })
getDeletes(ack?: SyncAck) {
return this.db
.selectFrom('users_audit')
.select(['id', 'userId'])
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [], stream: true })
getUpserts(ack?: SyncAck) {
return this.db
.selectFrom('users')
.select(['id', 'name', 'email', 'deletedAt', 'updateId'])
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
@@ -100,6 +100,16 @@ export class UserRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForChangePassword(id: string) {
return this.db
.selectFrom('users')
.select(['users.id', 'users.password'])
.where('users.id', '=', id)
.where('users.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, options?: { withPassword?: boolean }) {
return this.db
+2 -2
View File
@@ -22,12 +22,12 @@ export class ViewRepository {
.where('localDateTime', 'is not', null)
.execute();
return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
return results.map((row) => row.directoryPath.replaceAll(/\/$/g, ''));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string) {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
const normalizedPath = partialPath.replaceAll(/\/$/g, '');
return this.db
.selectFrom('assets')
+42
View File
@@ -142,6 +142,20 @@ export const albums_delete_audit = registerFunction({
synchronize: false,
});
export const album_assets_delete_audit = registerFunction({
name: 'album_assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_assets_audit ("albumId", "assetId")
SELECT "albumsId", "assetsId" FROM OLD
WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD));
RETURN NULL;
END`,
synchronize: false,
});
export const album_users_delete_audit = registerFunction({
name: 'album_users_delete_audit',
returnType: 'TRIGGER',
@@ -162,3 +176,31 @@ export const album_users_delete_audit = registerFunction({
END`,
synchronize: false,
});
export const memories_delete_audit = registerFunction({
name: 'memories_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO memories_audit ("memoryId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const memory_assets_delete_audit = registerFunction({
name: 'memory_assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO memory_assets_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetsId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END`,
synchronize: false,
});
+12 -2
View File
@@ -8,11 +8,14 @@ import {
f_unaccent,
immich_uuid_v7,
ll_to_earth_public,
memories_delete_audit,
memory_assets_delete_audit,
partners_delete_audit,
updated_at,
users_delete_audit,
} from 'src/schema/functions';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
@@ -29,8 +32,10 @@ import { ExifTable } from 'src/schema/tables/exif.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
@@ -58,6 +63,7 @@ export class ImmichDatabase {
tables = [
ActivityTable,
AlbumAssetTable,
AlbumAssetAuditTable,
AlbumAuditTable,
AlbumUserAuditTable,
AlbumUserTable,
@@ -73,8 +79,10 @@ export class ImmichDatabase {
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MemoryAuditTable,
MemoryAssetTable,
MemoryAssetAuditTable,
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
@@ -108,6 +116,8 @@ export class ImmichDatabase {
albums_delete_audit,
album_user_after_insert,
album_users_delete_audit,
memories_delete_audit,
memory_assets_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
@@ -0,0 +1,18 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "albums_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_update_id" ON "albums_assets_assets" ("updateId")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at"
BEFORE UPDATE ON "albums_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_album_assets_update_id";`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db);
}
@@ -0,0 +1,22 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "album_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_album_id" ON "album_assets_audit" ("albumId")`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_asset_id" ON "album_assets_audit" ("assetId")`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_deleted_at" ON "album_assets_audit" ("deletedAt")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at"
BEFORE UPDATE ON "albums_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_album_id";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_asset_id";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_deleted_at";`.execute(db);
await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d";`.execute(db);
await sql`DROP TABLE "album_assets_audit";`.execute(db);
}
@@ -0,0 +1,28 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_assets_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO album_assets_audit ("albumId", "assetId")
SELECT "albumsId", "assetsId" FROM OLD
WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "FK_8047b44b812619a3c75a2839b0d" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_delete_audit"
AFTER DELETE ON "albums_assets_assets"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION album_assets_delete_audit();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "album_assets_delete_audit" ON "albums_assets_assets";`.execute(db);
await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "FK_8047b44b812619a3c75a2839b0d";`.execute(db);
await sql`DROP FUNCTION album_assets_delete_audit;`.execute(db);
}
@@ -0,0 +1,80 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "memory_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`CREATE TABLE "memories_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "PK_35ef16910228f980e0766dcc59b" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "memories_audit" ADD CONSTRAINT "PK_19de798c033a710dcfa5c72f81b" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "FK_225a204afcb0bd6de015080fb03" FOREIGN KEY ("memoryId") REFERENCES "memories" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_memory_id" ON "memory_assets_audit" ("memoryId")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_asset_id" ON "memory_assets_audit" ("assetId")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_deleted_at" ON "memory_assets_audit" ("deletedAt")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_update_id" ON "memories_assets_assets" ("updateId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_memory_id" ON "memories_audit" ("memoryId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_user_id" ON "memories_audit" ("userId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_deleted_at" ON "memories_audit" ("deletedAt")`.execute(db);
await sql`CREATE OR REPLACE FUNCTION memories_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO memories_audit ("memoryId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION memory_assets_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO memory_assets_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetsId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memories_delete_audit"
AFTER DELETE ON "memories"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION memories_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memory_assets_delete_audit"
AFTER DELETE ON "memories_assets_assets"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION memory_assets_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memory_assets_updated_at"
BEFORE UPDATE ON "memories_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "memories_delete_audit" ON "memories";`.execute(db);
await sql`DROP TRIGGER "memory_assets_delete_audit" ON "memories_assets_assets";`.execute(db);
await sql`DROP TRIGGER "memory_assets_updated_at" ON "memories_assets_assets";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_update_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_memory_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_asset_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_deleted_at";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_memory_id";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_user_id";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_deleted_at";`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "FK_225a204afcb0bd6de015080fb03";`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "PK_35ef16910228f980e0766dcc59b";`.execute(db);
await sql`ALTER TABLE "memories_audit" DROP CONSTRAINT "PK_19de798c033a710dcfa5c72f81b";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "createdAt";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TABLE "memory_assets_audit";`.execute(db);
await sql`DROP TABLE "memories_audit";`.execute(db);
await sql`DROP FUNCTION memories_delete_audit;`.execute(db);
await sql`DROP FUNCTION memory_assets_delete_audit;`.execute(db);
}
@@ -0,0 +1,23 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { AlbumTable } from 'src/schema/tables/album.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('album_assets_audit')
export class AlbumAssetAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: string;
@ForeignKeyColumn(() => AlbumTable, {
type: 'uuid',
indexName: 'IDX_album_assets_audit_album_id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
albumId!: string;
@Column({ type: 'uuid', indexName: 'IDX_album_assets_audit_asset_id' })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_assets_audit_deleted_at' })
deletedAt!: Date;
}
+17 -1
View File
@@ -1,8 +1,18 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { album_assets_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
@UpdatedAtTrigger('album_assets_updated_at')
@AfterDeleteTrigger({
name: 'album_assets_delete_audit',
scope: 'statement',
function: album_assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class AlbumAssetTable {
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
albumsId!: string;
@@ -12,4 +22,10 @@ export class AlbumAssetTable {
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@UpdateIdColumn({ indexName: 'IDX_album_assets_update_id' })
updateId!: string;
}
+1 -1
View File
@@ -13,8 +13,8 @@ import 'src/schema/tables/exif.table';
import 'src/schema/tables/face-search.table';
import 'src/schema/tables/geodata-places.table';
import 'src/schema/tables/library.table';
import 'src/schema/tables/memory-asset.table';
import 'src/schema/tables/memory.table';
import 'src/schema/tables/memory_asset.table';
import 'src/schema/tables/move.table';
import 'src/schema/tables/natural-earth-countries.table';
import 'src/schema/tables/partner-audit.table';
@@ -0,0 +1,23 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memory_assets_audit')
export class MemoryAssetAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: string;
@ForeignKeyColumn(() => MemoryTable, {
type: 'uuid',
indexName: 'IDX_memory_assets_audit_memory_id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
memoryId!: string;
@Column({ type: 'uuid', indexName: 'IDX_memory_assets_audit_asset_id' })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memory_assets_audit_deleted_at' })
deletedAt!: Date;
}
@@ -0,0 +1,36 @@
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { memory_assets_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('memories_assets_assets')
@UpdatedAtTrigger('memory_assets_updated_at')
@AfterDeleteTrigger({
name: 'memory_assets_delete_audit',
scope: 'statement',
function: memory_assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class MemoryAssetTable {
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ indexName: 'IDX_memory_assets_update_id' })
updateId!: Generated<string>;
}
@@ -0,0 +1,22 @@
import { ColumnType } from 'kysely';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('memories_audit')
export class MemoryAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', indexName: 'IDX_memories_audit_memory_id' })
memoryId!: string;
@Column({ type: 'uuid', indexName: 'IDX_memories_audit_user_id' })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memories_audit_deleted_at' })
deletedAt!: Timestamp;
}
+12 -4
View File
@@ -1,7 +1,9 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum';
import { memories_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@@ -10,11 +12,17 @@ import {
Table,
UpdateDateColumn,
} from 'src/sql-tools';
import { MemoryData } from 'src/types';
@Table('memories')
@UpdatedAtTrigger('memories_updated_at')
export class MemoryTable<T extends MemoryType = MemoryType> {
@AfterDeleteTrigger({
name: 'memories_delete_audit',
scope: 'statement',
function: memories_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class MemoryTable {
@PrimaryGeneratedColumn()
id!: string;
@@ -31,10 +39,10 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
ownerId!: string;
@Column()
type!: T;
type!: MemoryType;
@Column({ type: 'jsonb' })
data!: MemoryData[T];
data!: object;
/** unless set to true, will be automatically deleted in the future */
@Column({ type: 'boolean', default: false })
@@ -1,12 +0,0 @@
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memories_assets_assets')
export class MemoryAssetTable {
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
}
+12 -24
View File
@@ -78,36 +78,24 @@ export class ApiService {
return next();
}
const targets = [
{
regex: /^\/share\/(.+)$/,
onMatch: async (matches: RegExpMatchArray) => {
const key = matches[1];
const auth = await this.authService.validateSharedLink(key);
return this.sharedLinkService.getMetadataTags(auth);
},
},
];
let status = 200;
let html = index;
try {
for (const { regex, onMatch } of targets) {
const matches = request.url.match(regex);
if (matches) {
const meta = await onMatch(matches);
if (meta) {
html = render(index, meta);
}
break;
const shareMatches = request.url.match(/^\/share\/(.+)$/);
if (shareMatches) {
try {
const key = shareMatches[1];
const auth = await this.authService.validateSharedLink(key);
const meta = await this.sharedLinkService.getMetadataTags(auth);
if (meta) {
html = render(index, meta);
}
} catch {
status = 404;
}
} catch {
// nothing to do here
}
res.type('text/html').header('Cache-Control', 'no-store').send(html);
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
};
}
}
+8 -21
View File
@@ -116,46 +116,33 @@ describe(AuthService.name, () => {
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' });
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
mocks.user.update.mockResolvedValue(user);
await sut.changePassword(auth, dto);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
});
it('should throw when auth user email is not found', async () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw when password does not match existing password', async () => {
const auth = { user: { email: 'test@imimch.com' } as UserAdmin };
const user = factory.user();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false);
mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: 'hash-password',
} as UserAdmin & { password: string });
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw when user does not have a password', async () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const user = factory.user();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: '',
} as UserAdmin & { password: string });
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});
+1 -5
View File
@@ -91,11 +91,7 @@ export class AuthService extends BaseService {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
if (!user) {
throw new UnauthorizedException();
}
const user = await this.userRepository.getForChangePassword(auth.user.id);
const valid = this.validateSecret(password, user.password);
if (!valid) {
throw new BadRequestException('Wrong password');
+50
View File
@@ -41,6 +41,7 @@ import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
@@ -53,6 +54,54 @@ import { UserTable } from 'src/schema/tables/user.table';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
export const BASE_SERVICE_DEPENDENCIES = [
LoggingRepository,
AccessRepository,
ActivityRepository,
AlbumRepository,
AlbumUserRepository,
ApiKeyRepository,
AssetRepository,
AssetJobRepository,
AuditRepository,
ConfigRepository,
CronRepository,
CryptoRepository,
DatabaseRepository,
DownloadRepository,
DuplicateRepository,
EmailRepository,
EventRepository,
JobRepository,
LibraryRepository,
MachineLearningRepository,
MapRepository,
MediaRepository,
MemoryRepository,
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthRepository,
PartnerRepository,
PersonRepository,
ProcessRepository,
SearchRepository,
ServerInfoRepository,
SessionRepository,
SharedLinkRepository,
StackRepository,
StorageRepository,
SyncRepository,
SyncCheckpointRepository,
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
TrashRepository,
UserRepository,
VersionHistoryRepository,
ViewRepository,
];
@Injectable()
export class BaseService {
protected storageCore: StorageCore;
@@ -95,6 +144,7 @@ export class BaseService {
protected stackRepository: StackRepository,
protected storageRepository: StorageRepository,
protected syncRepository: SyncRepository,
protected syncCheckpointRepository: SyncCheckpointRepository,
protected systemMetadataRepository: SystemMetadataRepository,
protected tagRepository: TagRepository,
protected telemetryRepository: TelemetryRepository,
+8 -3
View File
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryService } from 'src/services/memory.service';
import { OnThisDayData } from 'src/types';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -87,7 +88,7 @@ describe(MemoryService.name, () => {
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type,
data: memory.data,
data: memory.data as OnThisDayData,
memoryAt: memory.memoryAt,
isSaved: memory.isSaved,
assetIds: [assetId],
@@ -117,7 +118,7 @@ describe(MemoryService.name, () => {
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type,
data: memory.data,
data: memory.data as OnThisDayData,
assetIds: memory.assets.map((asset) => asset.id),
memoryAt: memory.memoryAt,
}),
@@ -135,7 +136,11 @@ describe(MemoryService.name, () => {
mocks.memory.create.mockResolvedValue(memory);
await expect(
sut.create(factory.auth(), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt }),
sut.create(factory.auth(), {
type: memory.type,
data: memory.data as OnThisDayData,
memoryAt: memory.memoryAt,
}),
).resolves.toBeDefined();
});
});
+77 -1
View File
@@ -6,7 +6,7 @@ import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { MetadataService } from 'src/services/metadata.service';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
@@ -1639,4 +1639,80 @@ describe(MetadataService.name, () => {
});
});
});
describe('firstDateTime', () => {
it('should ignore date-only tags like GPSDateStamp', () => {
const tags = {
GPSDateStamp: '2023:08:08', // Date-only tag, should be ignored
SonyDateTime2: '2023:07:07 07:00:00',
};
const result = firstDateTime(tags);
expect(result?.tag).toBe('SonyDateTime2');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
});
it('should respect full priority order with all date tags present', () => {
const tags = {
// SubSec and standard EXIF date tags
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
SubSecCreateDate: '2023:02:02 02:00:00',
SubSecMediaCreateDate: '2023:03:03 03:00:00',
DateTimeOriginal: '2023:04:04 04:00:00',
CreateDate: '2023:05:05 05:00:00',
MediaCreateDate: '2023:06:06 06:00:00',
CreationDate: '2023:07:07 07:00:00',
DateTimeCreated: '2023:08:08 08:00:00',
// Additional date tags
TimeCreated: '2023:09:09 09:00:00',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: '2023:11:11 11:00:00',
GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored
SonyDateTime2: '2023:13:13 13:00:00',
// Non-standard tag
SourceImageCreateTime: '2023:14:14 14:00:00',
};
const result = firstDateTime(tags);
// Should use SubSecDateTimeOriginal as it has highest priority
expect(result?.tag).toBe('SubSecDateTimeOriginal');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-01-01T01:00:00.000Z');
});
it('should handle missing SubSec tags and use available date tags', () => {
const tags = {
// Standard date tags
CreationDate: '2023:07:07 07:00:00',
DateTimeCreated: '2023:08:08 08:00:00',
// Additional date tags
TimeCreated: '2023:09:09 09:00:00',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: '2023:11:11 11:00:00',
GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored
SonyDateTime2: '2023:13:13 13:00:00',
};
const result = firstDateTime(tags);
// Should use CreationDate when available
expect(result?.tag).toBe('CreationDate');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
});
it('should handle invalid date formats gracefully', () => {
const tags = {
TimeCreated: 'invalid-date',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: 'also-invalid',
SonyDateTime2: '2023:13:13 13:00:00',
};
const result = firstDateTime(tags);
// Should skip invalid dates and use the first valid one
expect(result?.tag).toBe('GPSDateTime');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z');
});
});
});
+42 -10
View File
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
import { Insertable } from 'kysely';
import _ from 'lodash';
import { Duration } from 'luxon';
@@ -32,19 +31,47 @@ import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof Tags> = [
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
'SubSecDateTimeOriginal',
'DateTimeOriginal',
'SubSecCreateDate',
'CreationDate',
'CreateDate',
'SubSecMediaCreateDate',
'DateTimeOriginal',
'CreateDate',
'MediaCreateDate',
'CreationDate',
'DateTimeCreated',
'GPSDateTime',
'DateTimeUTC',
'SonyDateTime2',
// Undocumented, non-standard tag from insta360 in xmp.GPano namespace
'SourceImageCreateTime' as keyof Tags,
'SourceImageCreateTime' as keyof ImmichTags,
];
export function firstDateTime(tags: ImmichTags) {
for (const tag of EXIF_DATE_TAGS) {
const tagValue = tags?.[tag];
if (tagValue instanceof ExifDateTime) {
return {
tag,
dateTime: tagValue,
};
}
if (typeof tagValue !== 'string') {
continue;
}
const exifDateTime = ExifDateTime.fromEXIF(tagValue);
if (exifDateTime) {
return {
tag,
dateTime: exifDateTime,
};
}
}
}
const validate = <T>(value: T): NonNullable<T> | null => {
// handle lists of numbers
if (Array.isArray(value)) {
@@ -407,7 +434,8 @@ export class MetadataService extends BaseService {
// prefer dates from sidecar tags
if (sidecarTags) {
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
const result = firstDateTime(sidecarTags);
const sidecarDate = result?.dateTime;
if (sidecarDate) {
for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag];
@@ -748,8 +776,12 @@ export class MetadataService extends BaseService {
}
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) {
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`);
const result = firstDateTime(exifTags);
const tag = result?.tag;
const dateTime = result?.dateTime;
this.logger.verbose(
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
);
// timezone
let timeZone = exifTags.tz ?? null;
+14 -4
View File
@@ -4,7 +4,7 @@ import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { Person } from 'src/database';
import { AssetFaces, FaceSearch } from 'src/db';
import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceCreateDto,
@@ -216,6 +216,10 @@ export class PersonService extends BaseService {
return mapPerson(person);
}
delete(auth: AuthDto, id: string): Promise<void> {
return this.deleteAll(auth, { ids: [id] });
}
async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
const results: BulkIdResponseDto[] = [];
for (const person of dto.people) {
@@ -236,8 +240,14 @@ export class PersonService extends BaseService {
return results;
}
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PERSON_DELETE, ids });
const people = await this.personRepository.getForPeopleDelete(ids);
await this.removeAllPeople(people);
}
@Chunked()
private async delete(people: { id: string; thumbnailPath: string }[]) {
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people.map((person) => person.id));
this.logger.debug(`Deleted ${people.length} people`);
@@ -246,7 +256,7 @@ export class PersonService extends BaseService {
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces();
await this.delete(people);
await this.removeAllPeople(people);
return JobStatus.SUCCESS;
}
@@ -589,7 +599,7 @@ export class PersonService extends BaseService {
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
await this.delete([mergePerson]);
await this.removeAllPeople([mergePerson]);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
results.push({ id: mergeId, success: true });
+245 -115
View File
@@ -57,11 +57,16 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.UsersV1,
SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1,
SyncRequestType.AssetExifsV1,
SyncRequestType.PartnerAssetsV1,
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumsV1,
SyncRequestType.AlbumUsersV1,
SyncRequestType.AlbumToAssetsV1,
SyncRequestType.AssetExifsV1,
SyncRequestType.AlbumAssetExifsV1,
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.MemoriesV1,
SyncRequestType.MemoryToAssetsV1,
];
const throwSessionRequired = () => {
@@ -76,7 +81,7 @@ export class SyncService extends BaseService {
return throwSessionRequired();
}
return this.syncRepository.getCheckpoints(sessionId);
return this.syncCheckpointRepository.getAll(sessionId);
}
async setAcks(auth: AuthDto, dto: SyncAckSetDto) {
@@ -93,14 +98,11 @@ export class SyncService extends BaseService {
throw new BadRequestException(`Invalid ack type: ${type}`);
}
if (checkpoints[type]) {
throw new BadRequestException('Only one ack per type is allowed');
}
// TODO pick the latest ack for each type, instead of using the last one
checkpoints[type] = { sessionId, type, ack };
}
await this.syncRepository.upsertCheckpoints(Object.values(checkpoints));
await this.syncCheckpointRepository.upsertAll(Object.values(checkpoints));
}
async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) {
@@ -109,7 +111,7 @@ export class SyncService extends BaseService {
return throwSessionRequired();
}
await this.syncRepository.deleteCheckpoints(sessionId, dto.types);
await this.syncCheckpointRepository.deleteAll(sessionId, dto.types);
}
async stream(auth: AuthDto, response: Writable, dto: SyncStreamDto) {
@@ -118,95 +120,72 @@ export class SyncService extends BaseService {
return throwSessionRequired();
}
const checkpoints = await this.syncRepository.getCheckpoints(sessionId);
const checkpoints = await this.syncCheckpointRepository.getAll(sessionId);
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
const handlers: Record<SyncRequestType, () => Promise<void>> = {
[SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap),
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth),
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(response, checkpointMap, auth),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(response, checkpointMap, auth),
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.MemoriesV1]: () => this.syncMemoriesV1(response, checkpointMap, auth),
[SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth),
};
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
switch (type) {
case SyncRequestType.UsersV1: {
await this.syncUsersV1(response, checkpointMap);
break;
}
case SyncRequestType.PartnersV1: {
await this.syncPartnersV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetsV1: {
await this.syncAssetsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.PartnerAssetExifsV1: {
await this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumsV1: {
await this.syncAlbumsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AlbumUsersV1: {
await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId);
break;
}
default: {
this.logger.warn(`Unsupported sync type: ${type}`);
break;
}
}
const handler = handlers[type];
await handler();
}
response.end();
}
private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) {
const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]);
const deleteType = SyncEntityType.UserDeleteV1;
const deletes = this.syncRepository.user.getDeletes(checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.UserDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]);
const upsertType = SyncEntityType.UserV1;
const upserts = this.syncRepository.user.getUpserts(checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.UserV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncPartnersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[SyncEntityType.PartnerDeleteV1]);
const deleteType = SyncEntityType.PartnerDeleteV1;
const deletes = this.syncRepository.partner.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.PartnerDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]);
const upsertType = SyncEntityType.PartnerV1;
const upserts = this.syncRepository.partner.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.PartnerV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[SyncEntityType.AssetDeleteV1]);
const deleteType = SyncEntityType.AssetDeleteV1;
const deletes = this.syncRepository.asset.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AssetDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]);
const upsertType = SyncEntityType.AssetV1;
const upserts = this.syncRepository.asset.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AssetV1, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
}
}
@@ -216,21 +195,17 @@ export class SyncService extends BaseService {
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
const upsertType = SyncEntityType.PartnerAssetV1;
const deleteType = SyncEntityType.PartnerAssetDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getPartnerAssetDeletes(auth.user.id, checkpointMap[deleteType]);
const deletes = this.syncRepository.partnerAsset.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId);
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const partners = await this.syncRepository.partner.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.PartnerAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@@ -241,7 +216,7 @@ export class SyncService extends BaseService {
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getPartnerAssetsBackfill(partner.sharedById, startId, endId);
const backfill = this.syncRepository.partnerAsset.getBackfill(partner.sharedById, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, {
@@ -261,16 +236,17 @@ export class SyncService extends BaseService {
});
}
const upserts = this.syncRepository.getPartnerAssetsUpserts(auth.user.id, checkpointMap[upsertType]);
const upserts = this.syncRepository.partnerAsset.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
}
}
private async syncAssetExifsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetExifV1]);
const upsertType = SyncEntityType.AssetExifV1;
const upserts = this.syncRepository.assetExif.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AssetExifV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
@@ -281,13 +257,11 @@ export class SyncService extends BaseService {
sessionId: string,
) {
const backfillType = SyncEntityType.PartnerAssetExifBackfillV1;
const upsertType = SyncEntityType.PartnerAssetExifV1;
const backfillCheckpoint = checkpointMap[backfillType];
const partners = await this.syncRepository.partner.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.PartnerAssetExifV1;
const upsertCheckpoint = checkpointMap[upsertType];
const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@@ -298,7 +272,7 @@ export class SyncService extends BaseService {
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getPartnerAssetExifsBackfill(partner.sharedById, startId, endId);
const backfill = this.syncRepository.partnerAssetExif.getBackfill(partner.sharedById, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [partner.createId, updateId], data });
@@ -314,40 +288,38 @@ export class SyncService extends BaseService {
});
}
const upserts = this.syncRepository.getPartnerAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]);
const upserts = this.syncRepository.partnerAssetExif.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[SyncEntityType.AlbumDeleteV1]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumDeleteV1, ids: [id], data });
}
const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AlbumV1, ids: [updateId], data });
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const upsertType = SyncEntityType.AlbumUserV1;
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]);
const deleteType = SyncEntityType.AlbumDeleteV1;
const deletes = this.syncRepository.album.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumV1;
const upserts = this.syncRepository.album.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const deletes = this.syncRepository.albumUser.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumUserV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@@ -358,7 +330,7 @@ export class SyncService extends BaseService {
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumUsersBackfill(album.id, startId, endId);
const backfill = this.syncRepository.albumUser.getBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data });
@@ -374,7 +346,165 @@ export class SyncService extends BaseService {
});
}
const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[upsertType]);
const upserts = this.syncRepository.albumUser.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.albumAsset.getBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.albumAsset.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
}
}
private async syncAlbumAssetExifsV1(
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumAssetExifBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumAssetExifV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.albumAssetExif.getBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.albumAssetExif.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumToAssetsV1(
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
sessionId: string,
) {
const deleteType = SyncEntityType.AlbumToAssetDeleteV1;
const deletes = this.syncRepository.albumToAsset.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.AlbumToAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumToAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.albumToAsset.getBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.albumToAsset.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.MemoryDeleteV1;
const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.MemoryV1;
const upserts = this.syncRepository.memory.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncMemoryAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.MemoryToAssetDeleteV1;
const deletes = this.syncRepository.memoryToAsset.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.MemoryToAssetV1;
const upserts = this.syncRepository.memoryToAsset.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
@@ -382,7 +512,7 @@ export class SyncService extends BaseService {
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncRepository.upsertCheckpoints([
await this.syncCheckpointRepository.upsertAll([
{
type,
sessionId,
+7 -12
View File
@@ -256,22 +256,17 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_UPDATE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_MERGE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_CREATE: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ:
case Permission.PERSON_UPDATE:
case Permission.PERSON_DELETE:
case Permission.PERSON_MERGE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_REASSIGN: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
+288 -325
View File
@@ -1,12 +1,13 @@
import { ClassConstructor } from 'class-transformer';
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum';
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';
@@ -16,6 +17,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
@@ -24,297 +26,291 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { SyncService } from 'src/services/sync.service';
import { RepositoryInterface } from 'src/types';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, ServiceOverrides, wait } from 'test/utils';
import { automock, wait } from 'test/utils';
import { Mocked } from 'vitest';
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T;
}
// type Repositories = Omit<ServiceOverrides, 'access' | 'telemetry'>;
type RepositoriesTypes = {
activity: ActivityRepository;
album: AlbumRepository;
albumUser: AlbumUserRepository;
asset: AssetRepository;
assetJob: AssetJobRepository;
config: ConfigRepository;
crypto: CryptoRepository;
database: DatabaseRepository;
email: EmailRepository;
job: JobRepository;
user: UserRepository;
logger: LoggingRepository;
memory: MemoryRepository;
notification: NotificationRepository;
partner: PartnerRepository;
person: PersonRepository;
search: SearchRepository;
session: SessionRepository;
sync: SyncRepository;
systemMetadata: SystemMetadataRepository;
versionHistory: VersionHistoryRepository;
};
type RepositoryMocks = { [K in keyof RepositoriesTypes]: Mocked<RepositoryInterface<RepositoriesTypes[K]>> };
type RepositoryOptions = Partial<{ [K in keyof RepositoriesTypes]: 'mock' | 'real' }>;
type ContextRepositoryMocks<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'mock' ? K : never]: Mocked<RepositoryInterface<RepositoriesTypes[K]>>;
type MediumTestOptions = {
mock: ClassConstructor<any>[];
real: ClassConstructor<any>[];
database: Kysely<DB>;
};
type ContextRepositories<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'real' ? K : never]: RepositoriesTypes[K];
export const newMediumService = <S extends BaseService>(Service: ClassConstructor<S>, options: MediumTestOptions) => {
const ctx = new MediumTestContext(Service, options);
return { sut: ctx.sut, ctx };
};
export type Context<R extends RepositoryOptions, S extends BaseService> = {
export class MediumTestContext<S extends BaseService = BaseService> {
private repoCache: Record<string, any> = {};
private sutDeps: any[];
sut: S;
mocks: ContextRepositoryMocks<R>;
repos: ContextRepositories<R>;
getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T];
};
database: Kysely<DB>;
export type SyncTestOptions = {
db: Kysely<DB>;
};
constructor(
Service: ClassConstructor<S>,
private options: MediumTestOptions,
) {
this.sutDeps = this.makeDeps(options);
this.sut = new Service(...this.sutDeps);
this.database = options.database;
}
export const newSyncAuthUser = () => {
const user = mediumFactory.userInsert();
const session = mediumFactory.sessionInsert({ userId: user.id });
private makeDeps(options: MediumTestOptions) {
const deps = BASE_SERVICE_DEPENDENCIES;
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
for (const dep of options.mock) {
if (!deps.includes(dep)) {
throw new Error(`Mocked repository ${dep.name} is not a valid dependency`);
}
}
return {
auth,
session,
user,
create: async (db: Kysely<DB>) => {
await new UserRepository(db).create(user);
await new SessionRepository(db).create(session);
},
};
};
for (const dep of options.real) {
if (!deps.includes(dep)) {
throw new Error(`Real repository ${dep.name} is not a valid dependency`);
}
}
return (deps as ClassConstructor<any>[]).map((dep) => {
if (options.real.includes(dep)) {
return this.get(dep);
}
export const newSyncTest = (options: SyncTestOptions) => {
const { sut, mocks, repos, getRepository } = newMediumService(SyncService, {
database: options.db,
repos: {
sync: 'real',
session: 'real',
},
});
if (options.mock.includes(dep)) {
return newMockRepository(dep);
}
});
}
const testSync = async (auth: AuthDto, types: SyncRequestType[]) => {
get<T>(key: ClassConstructor<T>): T {
if (!this.repoCache[key.name]) {
const real = newRealRepository(key, this.options.database);
this.repoCache[key.name] = real;
}
return this.repoCache[key.name];
}
getMock<T, R = Mocked<T>>(key: ClassConstructor<T>): R {
const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any);
if (index === -1 || !this.options.mock.includes(key)) {
throw new Error(`getMock called with a key that is not a mock: ${key.name}`);
}
return this.sutDeps[index] as R;
}
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
const user = mediumFactory.userInsert(dto);
const result = await this.get(UserRepository).create(user);
return { user, result };
}
async newPartner(dto: { sharedById: string; sharedWithId: string; inTimeline?: boolean }) {
const partner = { inTimeline: true, ...dto };
const result = await this.get(PartnerRepository).create(partner);
return { partner, result };
}
async newAsset(dto: Partial<Insertable<Assets>> = {}) {
const asset = mediumFactory.assetInsert(dto);
const result = await this.get(AssetRepository).create(asset);
return { asset, result };
}
async newMemory(dto: Partial<Insertable<Memories>> = {}) {
const memory = mediumFactory.memoryInsert(dto);
const result = await this.get(MemoryRepository).create(memory, new Set<string>());
return { memory, result };
}
async newMemoryAsset(dto: { memoryId: string; assetId: string }) {
const result = await this.get(MemoryRepository).addAssetIds(dto.memoryId, [dto.assetId]);
return { memoryAsset: dto, result };
}
async newExif(dto: Insertable<Exif>) {
const result = await this.get(AssetRepository).upsertExif(dto);
return { result };
}
async newAlbum(dto: Insertable<Albums>) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []);
return { album, result };
}
async newAlbumAsset(albumAsset: { albumId: string; assetId: string }) {
const result = await this.get(AlbumRepository).addAssetIds(albumAsset.albumId, [albumAsset.assetId]);
return { albumAsset, result };
}
async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) {
const { albumId, userId, role = AlbumUserRole.EDITOR } = dto;
const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role });
return { albumUser: { albumId, userId, role }, result };
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatus>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
return { jobStatus, result };
}
async newPerson(dto: Partial<Insertable<Person>> & { ownerId: string }) {
const person = mediumFactory.personInsert(dto);
const result = await this.get(PersonRepository).create(person);
return { person, result };
}
async newSession(dto: Partial<Insertable<Sessions>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
return { session, result };
}
async newSyncAuthUser() {
const { user } = await this.newUser();
const { session } = await this.newSession({ userId: user.id });
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
return {
auth,
session,
user,
};
}
}
export class SyncTestContext extends MediumTestContext<SyncService> {
constructor(database: Kysely<DB>) {
super(SyncService, {
database,
real: [SyncRepository, SyncCheckpointRepository, SessionRepository],
mock: [LoggingRepository],
});
}
async syncStream(auth: AuthDto, types: SyncRequestType[]) {
const stream = mediumFactory.syncStream();
// Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy
await wait(2);
await sut.stream(auth, stream, { types });
await this.sut.stream(auth, stream, { types });
return stream.getResponse();
};
return {
sut,
mocks,
repos,
getRepository,
testSync,
};
};
export const newMediumService = <R extends RepositoryOptions, S extends BaseService>(
Service: ClassConstructor<S>,
options: {
database: Kysely<DB>;
repos: R;
},
): Context<R, S> => {
const repos: Partial<RepositoriesTypes> = {};
const mocks: Partial<RepositoryMocks> = {};
const loggerMock = getRepositoryMock('logger') as Mocked<LoggingRepository>;
loggerMock.setContext.mockImplementation(() => {});
repos.logger = loggerMock;
for (const [_key, type] of Object.entries(options.repos)) {
if (type === 'real') {
const key = _key as keyof RepositoriesTypes;
repos[key] = getRepository(key, options.database) as any;
continue;
}
if (type === 'mock') {
const key = _key as keyof RepositoryMocks;
mocks[key] = getRepositoryMock(key) as any;
continue;
}
}
const makeRepository = <K extends keyof RepositoriesTypes>(key: K) => {
return repos[key] || getRepository(key, options.database);
};
async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) {
const acks: Record<string, string> = {};
for (const { type, ack } of response) {
acks[type] = ack;
}
const deps = asDeps({ ...mocks, ...repos } as ServiceOverrides);
const sut = new Service(...deps);
await this.sut.setAcks(auth, { acks: Object.values(acks) });
}
}
return {
sut,
mocks,
repos,
getRepository: makeRepository,
} as Context<R, S>;
};
export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kysely<DB>) => {
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case 'activity': {
return new ActivityRepository(db);
case AccessRepository:
case AlbumRepository:
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
case AssetJobRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return new key(db);
}
case 'album': {
return new AlbumRepository(db);
case ConfigRepository:
case CryptoRepository: {
return new key();
}
case 'albumUser': {
return new AlbumUserRepository(db);
case DatabaseRepository: {
return new key(db, LoggingRepository.create(), new ConfigRepository());
}
case 'asset': {
return new AssetRepository(db);
case EmailRepository: {
return new key(LoggingRepository.create());
}
case 'assetJob': {
return new AssetJobRepository(db);
}
case 'config': {
return new ConfigRepository();
}
case 'crypto': {
return new CryptoRepository();
}
case 'database': {
return new DatabaseRepository(db, LoggingRepository.create(), new ConfigRepository());
}
case 'email': {
return new EmailRepository(LoggingRepository.create());
}
case 'logger': {
return LoggingRepository.create();
}
case 'memory': {
return new MemoryRepository(db);
}
case 'notification': {
return new NotificationRepository(db);
}
case 'partner': {
return new PartnerRepository(db);
}
case 'person': {
return new PersonRepository(db);
}
case 'search': {
return new SearchRepository(db);
}
case 'session': {
return new SessionRepository(db);
}
case 'sync': {
return new SyncRepository(db);
}
case 'systemMetadata': {
return new SystemMetadataRepository(db);
}
case 'user': {
return new UserRepository(db);
}
case 'versionHistory': {
return new VersionHistoryRepository(db);
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
return new key() as unknown as T;
}
default: {
throw new Error(`Invalid repository key: ${key}`);
throw new Error(`Unable to create repository instance for key: ${key?.name || key}`);
}
}
};
const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
const newMockRepository = <T>(key: ClassConstructor<T>) => {
switch (key) {
case 'activity': {
return automock(ActivityRepository) as Mocked<RepositoryInterface<ActivityRepository>>;
case ActivityRepository:
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return automock(key);
}
case 'album': {
return automock(AlbumRepository);
}
case 'asset': {
return automock(AssetRepository);
}
case 'assetJob': {
return automock(AssetJobRepository);
}
case 'config': {
return automock(ConfigRepository);
}
case 'crypto': {
return automock(CryptoRepository);
}
case 'database': {
case DatabaseRepository: {
return automock(DatabaseRepository, {
args: [
undefined,
{
setContext: () => {},
},
{ getEnv: () => ({ database: { vectorExtension: '' } }) },
],
args: [undefined, { setContext: () => {} }, { getEnv: () => ({ database: { vectorExtension: '' } }) }],
});
}
case 'email': {
return automock(EmailRepository, {
args: [
{
setContext: () => {},
},
],
});
case EmailRepository: {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
case 'job': {
case EventRepository: {
return automock(EventRepository, { args: [undefined, undefined, { setContext: () => {} }] });
}
case JobRepository: {
return automock(JobRepository, {
args: [
undefined,
@@ -327,45 +323,13 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
});
}
case 'logger': {
case LoggingRepository as unknown as ClassConstructor<T>: {
const configMock = { getEnv: () => ({ noColor: false }) };
return automock(LoggingRepository, { args: [undefined, configMock], strict: false });
}
case 'memory': {
return automock(MemoryRepository);
}
case 'notification': {
return automock(NotificationRepository);
}
case 'partner': {
return automock(PartnerRepository);
}
case 'person': {
return automock(PersonRepository);
}
case 'session': {
return automock(SessionRepository);
}
case 'sync': {
return automock(SyncRepository);
}
case 'systemMetadata': {
return automock(SystemMetadataRepository);
}
case 'user': {
return automock(UserRepository);
}
case 'versionHistory': {
return automock(VersionHistoryRepository);
case StorageRepository: {
return automock(StorageRepository, { args: [{ setContext: () => {} }] });
}
default: {
@@ -374,55 +338,6 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
}
};
export const asDeps = (repositories: ServiceOverrides) => {
return [
repositories.logger || getRepositoryMock('logger'), // logger
repositories.access, // access
repositories.activity || getRepositoryMock('activity'),
repositories.album || getRepositoryMock('album'),
repositories.albumUser,
repositories.apiKey,
repositories.asset || getRepositoryMock('asset'),
repositories.assetJob || getRepositoryMock('assetJob'),
repositories.audit,
repositories.config || getRepositoryMock('config'),
repositories.cron,
repositories.crypto || getRepositoryMock('crypto'),
repositories.database || getRepositoryMock('database'),
repositories.downloadRepository,
repositories.duplicateRepository,
repositories.email || getRepositoryMock('email'),
repositories.event,
repositories.job || getRepositoryMock('job'),
repositories.library,
repositories.machineLearning,
repositories.map,
repositories.media,
repositories.memory || getRepositoryMock('memory'),
repositories.metadata,
repositories.move,
repositories.notification || getRepositoryMock('notification'),
repositories.oauth,
repositories.partner || getRepositoryMock('partner'),
repositories.person || getRepositoryMock('person'),
repositories.process,
repositories.search,
repositories.serverInfo,
repositories.session || getRepositoryMock('session'),
repositories.sharedLink,
repositories.stack,
repositories.storage,
repositories.sync || getRepositoryMock('sync'),
repositories.systemMetadata || getRepositoryMock('systemMetadata'),
repositories.tag,
repositories.telemetry,
repositories.trash,
repositories.user,
repositories.versionHistory || getRepositoryMock('versionHistory'),
repositories.view,
];
};
const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
const id = asset.id || newUuid();
const now = newDate();
@@ -532,6 +447,8 @@ const personInsert = (person: Partial<Insertable<Person>> & { ownerId: string })
};
};
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertable<Sessions>> & { userId: string }) => {
const defaults: Insertable<Sessions> = {
id,
@@ -549,15 +466,40 @@ const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertabl
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
const id = user.id || newUuid();
const defaults: Insertable<UserTable> = {
const defaults = {
email: `${id}@immich.cloud`,
name: `User ${id}`,
deletedAt: null,
isAdmin: false,
profileImagePath: '',
shouldChangePassword: true,
};
return { ...defaults, ...user, id };
};
const memoryInsert = (memory: Partial<Insertable<Memories>> = {}) => {
const id = memory.id || newUuid();
const date = newDate();
const defaults: Insertable<Memories> = {
id,
createdAt: date,
updatedAt: date,
deletedAt: null,
type: MemoryType.ON_THIS_DAY,
data: { year: 2025 },
showAt: null,
hideAt: null,
seenAt: null,
isSaved: false,
memoryAt: date,
ownerId: memory.ownerId || newUuid(),
};
return { ...defaults, ...memory, id };
};
class CustomWritable extends Writable {
private data = '';
@@ -579,6 +521,24 @@ const syncStream = () => {
return new CustomWritable();
};
const loginDetails = () => {
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' };
};
const loginResponse = (): LoginResponseDto => {
const user = userInsert({});
return {
accessToken: 'access-token',
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: user.shouldChangePassword,
isOnboarded: false,
};
};
export const mediumFactory = {
assetInsert,
assetFaceInsert,
@@ -589,4 +549,7 @@ export const mediumFactory = {
sessionInsert,
syncStream,
userInsert,
memoryInsert,
loginDetails,
loginResponse,
};
@@ -1,44 +1,33 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AssetRepository } from 'src/repositories/asset.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetService } from 'src/services/asset.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
describe(AssetService.name, () => {
let defaultDatabase: Kysely<DB>;
let assetRepo: AssetRepository;
let userRepo: UserRepository;
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
repos: {
asset: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
assetRepo = new AssetRepository(defaultDatabase);
userRepo = new UserRepository(defaultDatabase);
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
real: [AssetRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetService.name, () => {
describe('getStatistics', () => {
it('should return stats as numbers, not strings', async () => {
const { sut } = createSut();
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
await userRepo.create(user);
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getStatistics(auth, {})).resolves.toEqual({ images: 1, total: 1, videos: 0 });
});
@@ -1,74 +1,63 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { partners_delete_audit } from 'src/schema/functions';
import { mediumFactory } from 'test/medium.factory';
import { BaseService } from 'src/services/base.service';
import { MediumTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
describe('audit', () => {
let defaultDatabase: Kysely<DB>;
let assetRepo: AssetRepository;
let userRepo: UserRepository;
let partnerRepo: PartnerRepository;
let ctx: MediumTestContext;
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
assetRepo = new AssetRepository(defaultDatabase);
userRepo = new UserRepository(defaultDatabase);
partnerRepo = new PartnerRepository(defaultDatabase);
ctx = new MediumTestContext(BaseService, {
database: await getKyselyDB(),
real: [],
mock: [LoggingRepository],
});
});
describe(partners_delete_audit.name, () => {
it('should not cascade user deletes to partners_audit', async () => {
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await Promise.all([userRepo.create(user1), userRepo.create(user2)]);
const partnerRepo = ctx.get(PartnerRepository);
const userRepo = ctx.get(UserRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
await userRepo.delete(user1, true);
await expect(
defaultDatabase.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
ctx.database.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('assets_audit', () => {
it('should not cascade user deletes to assets_audit', async () => {
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
await userRepo.create(user);
await assetRepo.create(asset);
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await userRepo.delete(user, true);
await expect(
defaultDatabase.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
ctx.database.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('exif', () => {
it('should automatically set updatedAt and updateId when the row is updated', async () => {
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await userRepo.create(user);
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const before = await defaultDatabase
const before = await ctx.database
.selectFrom('exif')
.select(['updatedAt', 'updateId'])
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow();
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon 2' });
await ctx.newExif({ assetId: asset.id, make: 'Canon 2' });
const after = await defaultDatabase
const after = await ctx.database
.selectFrom('exif')
.select(['updatedAt', 'updateId'])
.where('assetId', '=', asset.id)
@@ -0,0 +1,163 @@
import { BadRequestException } from '@nestjs/common';
import { hash } from 'bcrypt';
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AuthType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { AuthService } from 'src/services/auth.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthService, {
database: db || defaultDatabase,
real: [
AccessRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
SessionRepository,
SystemMetadataRepository,
UserRepository,
],
mock: [LoggingRepository, StorageRepository, EventRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AuthService.name, () => {
describe('adminSignUp', () => {
it(`should sign up the admin`, async () => {
const { sut } = setup();
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
await expect(sut.adminSignUp(dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
email: dto.email,
name: dto.name,
isAdmin: true,
}),
);
});
it('should not allow a second admin to sign up', async () => {
const { sut, ctx } = setup();
await ctx.newUser({ isAdmin: true });
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
const response = sut.adminSignUp(dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('The server already has an admin');
});
});
describe('login', () => {
it('should reject an incorrect password', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password');
});
it('should accept a correct password and return a login response', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({
accessToken: expect.any(String),
isAdmin: user.isAdmin,
isOnboarded: false,
name: user.name,
profileImagePath: user.profileImagePath,
userId: user.id,
userEmail: user.email,
shouldChangePassword: user.shouldChangePassword,
});
});
});
describe('logout', () => {
it('should logout', async () => {
const { sut } = setup();
const auth = factory.auth();
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
it('should cleanup the session', async () => {
const { sut, ctx } = setup();
const sessionRepo = ctx.get(SessionRepository);
const eventRepo = ctx.getMock(EventRepository);
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
const auth = factory.auth({ session, user });
eventRepo.emit.mockResolvedValue();
await expect(sessionRepo.get(session.id)).resolves.toEqual(expect.objectContaining({ id: session.id }));
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
await expect(sessionRepo.get(session.id)).resolves.toBeUndefined();
});
});
describe('changePassword', () => {
it('should change the password and login with it', async () => {
const { sut, ctx } = setup();
const dto = { password: 'password', newPassword: 'new-password' };
const passwordHashed = await hash(dto.password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = await sut.changePassword(auth, dto);
expect(response).toEqual(
expect.objectContaining({
id: user.id,
email: user.email,
}),
);
expect((response as any).password).not.toBeDefined();
await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()),
).resolves.toBeDefined();
});
it('should validate the current password', async () => {
const { sut, ctx } = setup();
const dto = { password: 'wrong-password', newPassword: 'new-password' };
const passwordHashed = await hash('password', 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = sut.changePassword(auth, dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('Wrong password');
});
});
});
@@ -1,72 +1,138 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { DB } from 'src/db';
import { AssetFileType } from 'src/enum';
import { AssetFileType, MemoryType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { MemoryService } from 'src/services/memory.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(MemoryService, {
database: db || defaultDatabase,
real: [
AccessRepository,
AssetRepository,
DatabaseRepository,
MemoryRepository,
UserRepository,
SystemMetadataRepository,
UserRepository,
PartnerRepository,
],
mock: [LoggingRepository],
});
};
describe(MemoryService.name, () => {
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(MemoryService, {
database: db || defaultDatabase,
repos: {
asset: 'real',
database: 'real',
memory: 'real',
user: 'real',
systemMetadata: 'real',
partner: 'real',
},
});
};
beforeEach(async () => {
defaultDatabase = await getKyselyDB();
const userRepo = new UserRepository(defaultDatabase);
const admin = mediumFactory.userInsert({ isAdmin: true });
await userRepo.create(admin);
});
describe('create', () => {
it('should create a new memory', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
};
await expect(sut.create(auth, dto)).resolves.toEqual({
id: expect.any(String),
type: dto.type,
data: dto.data,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
isSaved: false,
memoryAt: dto.memoryAt,
ownerId: user.id,
assets: [],
});
});
it('should create a new memory (with assets)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const auth = factory.auth({ user });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
assetIds: [asset1.id, asset2.id],
};
await expect(sut.create(auth, dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })],
}),
);
});
it('should create a new memory and ignore assets the user does not have access to', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user1.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const auth = factory.auth({ user: user1 });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
assetIds: [asset1.id, asset2.id],
};
await expect(sut.create(auth, dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
assets: [expect.objectContaining({ id: asset1.id })],
}),
);
});
});
describe('onMemoryCreate', () => {
it('should work on an empty database', async () => {
const { sut } = createSut();
const { sut } = setup();
await expect(sut.onMemoriesCreate()).resolves.not.toThrow();
});
it('should create a memory from an asset', async () => {
const { sut, repos, getRepository } = createSut();
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const memoryRepo = ctx.get(MemoryRepository);
const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }) as DateTime<true>;
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({
ownerId: user.id,
localDateTime: now.minus({ years: 1 }).toISO(),
});
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: asset.id });
const userRepo = getRepository('user');
const assetRepo = getRepository('asset');
await userRepo.create(user);
await assetRepo.create(asset);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: now.minus({ years: 1 }).toISO() });
await Promise.all([
assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }),
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
ctx.newJobStatus({ assetId: asset.id }),
assetRepo.upsertFiles([
{ assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' },
{ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' },
]),
assetRepo.upsertJobStatus(jobStatus),
]);
vi.setSystemTime(now.toJSDate());
await sut.onMemoriesCreate();
const memories = await repos.memory.search(user.id, {});
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect.objectContaining({
@@ -88,16 +154,11 @@ describe(MemoryService.name, () => {
});
it('should not generate a memory twice for the same day', async () => {
const { sut, repos, getRepository } = createSut();
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const memoryRepo = ctx.get(MemoryRepository);
const now = DateTime.fromObject({ year: 2025, month: 2, day: 20 }, { zone: 'utc' }) as DateTime<true>;
const assetRepo = getRepository('asset');
const memoryRepo = getRepository('memory');
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { user } = await ctx.newUser();
for (const dto of [
{
ownerId: user.id,
@@ -112,11 +173,10 @@ describe(MemoryService.name, () => {
localDateTime: now.minus({ year: 1 }).plus({ days: 5 }).toISO(),
},
]) {
const asset = mediumFactory.assetInsert(dto);
await assetRepo.create(asset);
const { asset } = await ctx.newAsset(dto);
await Promise.all([
assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }),
assetRepo.upsertJobStatus(mediumFactory.assetJobStatusInsert({ assetId: asset.id })),
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
ctx.newJobStatus({ assetId: asset.id }),
assetRepo.upsertFiles([
{ assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' },
{ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' },
@@ -125,13 +185,13 @@ describe(MemoryService.name, () => {
}
vi.setSystemTime(now.toJSDate());
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
await sut.onMemoriesCreate();
const memoriesAfter = await memoryRepo.search(user.id, {});
expect(memoriesAfter.length).toBe(1);
});
@@ -139,7 +199,7 @@ describe(MemoryService.name, () => {
describe('onMemoriesCleanup', () => {
it('should run without error', async () => {
const { sut } = createSut();
const { sut } = setup();
await expect(sut.onMemoriesCleanup()).resolves.not.toThrow();
});
});
@@ -0,0 +1,80 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AccessRepository } from 'src/repositories/access.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { PersonService } from 'src/services/person.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository],
mock: [LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(PersonService.name, () => {
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const personRepo = ctx.get(PersonRepository);
const storageMock = ctx.getMock(StorageRepository);
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
describe('deleteAll', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { person: person1 } = await ctx.newPerson({ ownerId: user.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined();
await expect(personRepo.getById(person1.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
});
});
});
@@ -2,72 +2,65 @@ import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { DB } from 'src/db';
import { ImmichEnvironment, JobName, JobStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { UserService } from 'src/services/user.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
describe(UserService.name, () => {
let defaultDatabase: Kysely<DB>;
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
process.env.IMMICH_ENV = ImmichEnvironment.TESTING;
const setup = (db?: Kysely<DB>) => {
process.env.IMMICH_ENV = ImmichEnvironment.TESTING;
return newMediumService(UserService, {
database: db || defaultDatabase,
repos: {
user: 'real',
crypto: 'real',
config: 'real',
job: 'mock',
systemMetadata: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { repos } = createSut();
await repos.user.create({ isAdmin: true, email: 'admin@immich.cloud' });
return newMediumService(UserService, {
database: db || defaultDatabase,
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
mock: [LoggingRepository, JobRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { ctx } = setup();
await ctx.newUser({ isAdmin: true, email: 'admin@immich.cloud' });
});
describe(UserService.name, () => {
describe('create', () => {
it('should create a user', async () => {
const { sut } = createSut();
const { sut } = setup();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual(
expect.objectContaining({ name: user.name, email: user.email }),
);
});
it('should reject user with duplicate email', async () => {
const { sut } = createSut();
const { sut } = setup();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut } = createSut();
const { sut } = setup();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();
});
});
describe('search', () => {
it('should get users', async () => {
const { sut, repos } = createSut();
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await Promise.all([repos.user.create(user1), repos.user.create(user2)]);
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user: user1 });
await expect(sut.search(auth)).resolves.toEqual(
@@ -81,10 +74,8 @@ describe(UserService.name, () => {
describe('get', () => {
it('should get a user', async () => {
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.get(user.id)).resolves.toEqual(
expect.objectContaining({
@@ -96,11 +87,8 @@ describe(UserService.name, () => {
});
it('should not return password', async () => {
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const result = await sut.get(user.id);
expect((result as any).password).toBeUndefined();
@@ -109,10 +97,9 @@ describe(UserService.name, () => {
describe('updateMe', () => {
it('should update a user', async () => {
const { sut, repos: repositories } = createSut();
const before = await repositories.user.create(mediumFactory.userInsert());
const auth = factory.auth({ user: { id: before.id } });
const { sut, ctx } = setup();
const { user, result: before } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const after = await sut.updateMe(auth, { name: `${before.name} Updated` });
expect(before.updatedAt).toBeDefined();
@@ -128,17 +115,13 @@ describe(UserService.name, () => {
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getLicense(auth)).rejects.toThrowError();
const after = await sut.setLicense(auth, license);
expect(after.licenseKey).toEqual(license.licenseKey);
expect(after.activationKey).toEqual(license.activationKey);
const getResponse = await sut.getLicense(auth);
expect(getResponse).toEqual(after);
});
@@ -146,7 +129,7 @@ describe(UserService.name, () => {
describe.sequential('handleUserDeleteCheck', () => {
beforeEach(async () => {
const { sut } = createSut();
const { sut } = setup();
// These tests specifically have to be sequential otherwise we hit race conditions with config changes applying in incorrect tests
const config = await sut.getConfig({ withCache: false });
config.user.deleteDelay = 7;
@@ -154,52 +137,43 @@ describe(UserService.name, () => {
});
it('should work when there are no deleted users', async () => {
const { sut, mocks } = createSut();
mocks.job.queueAll.mockResolvedValue(void 0);
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
it('should work when there is a user to delete', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
const { user } = await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([
{ name: JobName.USER_DELETION, data: { id: user.id } },
]);
});
it('should skip a recently deleted user', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 5 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 5 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
it('should respect a custom user delete delay', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 25 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 25 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
const config = await sut.getConfig({ withCache: false });
config.user.deleteDelay = 30;
await sut.updateConfig(config);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
});
});
@@ -2,38 +2,40 @@ import { Kysely } from 'kysely';
import { serverVersion } from 'src/constants';
import { DB } from 'src/db';
import { JobName } from 'src/enum';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VersionService } from 'src/services/version.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
describe(VersionService.name, () => {
let defaultDatabase: Kysely<DB>;
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
repos: {
job: 'mock',
database: 'real',
versionHistory: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
real: [DatabaseRepository, VersionHistoryRepository],
mock: [LoggingRepository, JobRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(VersionService.name, () => {
describe('onBootstrap', () => {
it('record the current version on startup', async () => {
const { sut, repos } = setup();
const { sut, ctx } = setup();
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
const itemsBefore = await repos.versionHistory.getAll();
const itemsBefore = await versionHistoryRepo.getAll();
expect(itemsBefore).toHaveLength(0);
await sut.onBootstrap();
const itemsAfter = await repos.versionHistory.getAll();
const itemsAfter = await versionHistoryRepo.getAll();
expect(itemsAfter).toHaveLength(1);
expect(itemsAfter[0]).toEqual({
createdAt: expect.any(Date),
@@ -43,22 +45,26 @@ describe(VersionService.name, () => {
});
it('should queue memory creation when upgrading from 1.128.0', async () => {
const { sut, repos, mocks } = setup();
mocks.job.queue.mockResolvedValue(void 0);
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
jobMock.queue.mockResolvedValue(void 0);
await repos.versionHistory.create({ version: 'v1.128.0' });
await versionHistoryRepo.create({ version: 'v1.128.0' });
await sut.onBootstrap();
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE });
});
it('should not queue memory creation when upgrading from 1.129.0', async () => {
const { sut, repos, mocks } = setup();
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
await repos.versionHistory.create({ version: 'v1.129.0' });
await versionHistoryRepo.create({ version: 'v1.129.0' });
await sut.onBootstrap();
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,180 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumAssetExifsV1, () => {
it('should detect and sync the first album asset exif', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.AlbumAssetExifV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
});
it('should sync album asset exif for own user', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset exif for unrelated user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets exif when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset1Owner.id, make: 'asset1Owner' });
await wait(2);
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset1User2.id, make: 'asset1User2' });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset2User2.id, make: 'asset2User2' });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset3User2.id, make: 'asset3User2' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
// ack initial album asset exif sync
await ctx.syncAckAll(auth, response);
// create a second album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await Promise.all(
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id].map((assetId) =>
ctx.newAlbumAsset({ albumId: album2.id, assetId }),
),
);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
});
});
@@ -0,0 +1,177 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumAssetsV1, () => {
it('should detect and sync the first album asset', async () => {
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({
originalFileName,
ownerId: user2.id,
checksum: Buffer.from(checksum, 'base64'),
thumbhash: Buffer.from(thumbhash, 'base64'),
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
deletedAt: null,
duration: '0:10:00.00000',
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: SyncEntityType.AlbumAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
});
it('should sync album asset for own user', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset for unrelated user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user3.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id });
await wait(2);
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
// create a second album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await Promise.all(
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id].map((assetId) =>
ctx.newAlbumAsset({ albumId: album2.id, assetId }),
),
);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
});
});
@@ -0,0 +1,264 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumToAssetsV1, () => {
it('should detect and sync the first album to asset relation', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should sync album to asset for owned albums', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync the album to asset for shared albums', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should not sync album to asset for an album owned by another user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should backfill album to assets when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id });
const { asset: album2Asset } = await ctx.newAsset({ ownerId: auth.user.id });
// Backfill album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: album2Asset.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album1.id,
assetId: album1Asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
// ack initial album to asset sync
await ctx.syncAckAll(auth, response);
// add user to backfill album
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album to asset relation
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
assetId: album2Asset.id,
}),
type: SyncEntityType.AlbumToAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumToAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await albumRepo.removeAssetIds(album.id, [asset.id]);
await wait(2);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted album to asset relation when an asset is deleted', async () => {
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await assetRepo.remove({ id: asset.id });
await wait(2);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted album to asset relation when the album is deleted', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await albumRepo.delete(album.id);
await wait(2);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
});
@@ -1,17 +1,16 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -20,198 +19,156 @@ beforeAll(async () => {
describe(SyncRequestType.AlbumUsersV1, () => {
it('should sync an album user with the correct properties', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { user } = await ctx.newUser();
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const user = mediumFactory.userInsert();
await userRepo.create(user);
const albumUser = { albumsId: album.id, usersId: user.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
});
describe('owner', () => {
it('should detect and sync a new shared user', async () => {
const { auth, testSync, getRepository } = await setup();
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync an updated shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: AlbumUserRole.VIEWER,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
userId: albumUser.usersId,
albumId: albumUser.albumId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
});
describe('shared user', () => {
it('should detect and sync a new shared user', async () => {
const { auth, testSync, getRepository } = await setup();
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user1.id });
const { albumUser } = await ctx.newAlbumUser({
albumId: album.id,
userId: auth.user.id,
role: AlbumUserRole.EDITOR,
});
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync an updated shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: owner } = await ctx.newUser();
const { user: user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: owner.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(2);
const owner = mediumFactory.userInsert();
const user = mediumFactory.userInsert();
await Promise.all([userRepo.create(owner), userRepo.create(user)]);
const album = mediumFactory.albumInsert({ ownerId: owner.id });
await albumRepo.create(
album,
[],
[
{ userId: auth.user.id, role: AlbumUserRole.EDITOR },
{ userId: user.id, role: AlbumUserRole.EDITOR },
],
);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(initialSyncResponse).toHaveLength(2);
const acks = [initialSyncResponse[1].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -222,39 +179,29 @@ describe(SyncRequestType.AlbumUsersV1, () => {
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: owner } = await ctx.newUser();
const { user: user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: owner.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const owner = mediumFactory.userInsert();
const user = mediumFactory.userInsert();
await Promise.all([userRepo.create(owner), userRepo.create(user)]);
const album = mediumFactory.albumInsert({ ownerId: owner.id });
await albumRepo.create(
album,
[],
[
{ userId: auth.user.id, role: AlbumUserRole.EDITOR },
{ userId: user.id, role: AlbumUserRole.EDITOR },
],
);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(initialSyncResponse).toHaveLength(2);
const acks = [initialSyncResponse[1].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(2);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -264,35 +211,27 @@ describe(SyncRequestType.AlbumUsersV1, () => {
type: SyncEntityType.AlbumUserDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should backfill album users when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await userRepo.create(user1);
await userRepo.create(user2);
const album1 = mediumFactory.albumInsert({ ownerId: user1.id });
const album2 = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album1, [], []);
await albumRepo.create(album2, [], []);
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id });
// backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user1.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.EDITOR });
await wait(2);
// initial album user
await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await wait(2);
// post checkpoint album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user2.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.EDITOR });
const response = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
@@ -307,15 +246,13 @@ describe(SyncRequestType.AlbumUsersV1, () => {
]);
// ack initial user
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
// get access to the backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(backfillResponse).toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -350,10 +287,8 @@ describe(SyncRequestType.AlbumUsersV1, () => {
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[1].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(finalResponse).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
});
});
+101 -94
View File
@@ -1,17 +1,17 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -20,11 +20,12 @@ beforeAll(async () => {
describe(SyncRequestType.AlbumsV1, () => {
it('should sync an album with the correct properties', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -35,14 +36,18 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync a new album', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -51,14 +56,19 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album delete', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -69,7 +79,10 @@ describe(SyncRequestType.AlbumsV1, () => {
]);
await albumRepo.delete(album.id);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
@@ -78,67 +91,60 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
describe('shared albums', () => {
it('should detect and sync an album create', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album share (share before sync)', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], []);
await albumUserRepo.create({ usersId: auth.user.id, albumsId: album.id, role: AlbumUserRole.EDITOR });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album share (share after sync)', async () => {
const { auth, getRepository, sut, testSync } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album: userAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
const { album: user2Album } = await ctx.newAlbum({ ownerId: user2.id });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const userAlbum = mediumFactory.albumInsert({ ownerId: auth.user.id });
const user2Album = mediumFactory.albumInsert({ ownerId: user2.id });
await Promise.all([albumRepo.create(user2Album, [], []), albumRepo.create(userAlbum, [], [])]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
expect(initialSyncResponse).toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: userAlbum.id }),
@@ -146,75 +152,76 @@ describe(SyncRequestType.AlbumsV1, () => {
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.EDITOR });
await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: user2Album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album delete`', async () => {
const { auth, getRepository, testSync, sut } = await setup();
const albumRepo = getRepository('album');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await albumRepo.delete(album.id);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album unshare as an album delete', async () => {
const { auth, getRepository, testSync, sut } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
});
});
@@ -1,100 +1,78 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncRequestType.AssetExifsV1, () => {
describe(SyncRequestType.AssetExifsV1, () => {
it('should detect and sync the first asset exif', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.AssetExifV1,
const response = await ctx.syncStream(auth, [SyncRequestType.AssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
]),
);
type: SyncEntityType.AssetExifV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toEqual([]);
});
it('should only sync asset exif for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { session } = await ctx.newSession({ userId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
});
});
@@ -1,35 +1,32 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AssetRepository } from 'src/repositories/asset.repository';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.AssetV1, () => {
describe(SyncEntityType.AssetV1, () => {
it('should detect and sync the first asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({
originalFileName,
ownerId: auth.user.id,
checksum: Buffer.from(checksum, 'base64'),
@@ -40,96 +37,70 @@ describe.concurrent(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
});
await assetRepo.create(asset);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: 'AssetV1',
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
]),
);
type: 'AssetV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await assetRepo.remove(asset);
const response = await testSync(auth, [SyncRequestType.AssetsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: 'AssetDeleteV1',
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
]),
);
type: 'AssetDeleteV1',
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([]);
});
it('should not sync an asset or asset delete for an unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
await assetRepo.remove(asset);
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
});
});
@@ -0,0 +1,84 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryToAssetV1, () => {
it('should detect and sync a memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
assetId: asset.id,
},
type: 'MemoryToAssetV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
memoryId: memory.id,
},
type: 'MemoryToAssetDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should not sync a memory to asset relation or delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { auth: auth2, user: user2 } = await ctx.newSyncAuthUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
});
});
@@ -0,0 +1,115 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryV1, () => {
it('should detect and sync the first memory with the right properties', async () => {
const { auth, user: user1, ctx } = await setup();
const { memory } = await ctx.newMemory({ ownerId: user1.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: memory.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: memory.deletedAt,
type: memory.type,
data: memory.data,
hideAt: memory.hideAt,
showAt: memory.showAt,
seenAt: memory.seenAt,
memoryAt: expect.any(String),
isSaved: memory.isSaved,
ownerId: memory.ownerId,
},
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
await memoryRepo.delete(memory.id);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
},
type: 'MemoryDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should sync a memory and then an update to that same memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await memoryRepo.update(memory.id, { seenAt: new Date() });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should not sync a memory or a memory delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { user: user2 } = await ctx.newUser();
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
await memoryRepo.delete(memory.id);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
});
@@ -1,18 +1,16 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -21,134 +19,89 @@ beforeAll(async () => {
describe(SyncRequestType.PartnerAssetExifsV1, () => {
it('should detect and sync the first partner asset exif', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.PartnerAssetExifV1,
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
]),
);
type: SyncEntityType.PartnerAssetExifV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should not sync partner asset exif for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
});
it('should not sync partner asset exif for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await Promise.all([userRepo.create(user2), userRepo.create(user3)]);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user3.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
});
it('should backfill partner asset exif when a partner shared their library with you', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'Canon' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'Canon' });
await wait(2);
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'Canon' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'Canon' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
@@ -162,189 +115,133 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
expect(backfillResponse).toHaveLength(2);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
await sut.setAcks(auth, { acks: [backfillAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should handle partners with users ids lower than a uuidv7', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert({ id: '00d4c0af-7695-4cf2-85e6-415eeaf449cb' });
const user3 = mediumFactory.userInsert({ id: '00e4c0af-7695-4cf2-85e6-415eeaf449cb' });
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'assetUser3' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser({ id: '00d4c0af-7695-4cf2-85e6-415eeaf449cb' });
const { user: user3 } = await ctx.newUser({ id: '00e4c0af-7695-4cf2-85e6-415eeaf449cb' });
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'assetUser3' });
await wait(2);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'assetUser2' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'assetUser2' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
await ctx.syncAckAll(auth, response);
// This checks that our ack upsert is correct
const ackUpsertResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(ackUpsertResponse).toEqual([]);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
const syncAckResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(syncAckResponse).toHaveLength(2);
expect(syncAckResponse).toEqual(
expect.arrayContaining([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const syncAckResponseAcks = syncAckResponse.map(({ ack }) => ack);
await sut.setAcks(auth, { acks: [syncAckResponseAcks[1]] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(finalResponse).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'assetUser3' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'assetUser3' });
await wait(2);
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'assetUser2' });
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'assetUser2' });
await wait(2);
await assetRepo.create(asset2User3);
await assetRepo.upsertExif({ assetId: asset2User3.id, make: 'asset2User3' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const { asset: asset2User3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset2User3.id, make: 'asset2User3' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(3);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
expect(backfillResponse).toHaveLength(3);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
const partnerAssetAck = backfillResponse[2].ack;
await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
});
@@ -1,18 +1,19 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AssetRepository } from 'src/repositories/asset.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -21,19 +22,15 @@ beforeAll(async () => {
describe(SyncRequestType.PartnerAssetsV1, () => {
it('should detect and sync the first partner asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const originalFileName = 'firstPartnerAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user2.id,
originalFileName,
checksum: Buffer.from(checksum, 'base64'),
@@ -44,315 +41,217 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
});
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: asset.id,
ownerId: asset.ownerId,
originalFileName,
thumbhash,
checksum,
deletedAt: null,
fileCreatedAt: date,
fileModifiedAt: date,
isFavorite: false,
localDateTime: date,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: SyncEntityType.PartnerAssetV1,
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
ownerId: asset.ownerId,
originalFileName,
thumbhash,
checksum,
deletedAt: null,
fileCreatedAt: date,
fileModifiedAt: date,
isFavorite: false,
localDateTime: date,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
]),
);
type: SyncEntityType.PartnerAssetV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted partner asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
const assetRepo = getRepository('asset');
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await assetRepo.remove(asset);
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: SyncEntityType.PartnerAssetDeleteV1,
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
]),
);
type: SyncEntityType.PartnerAssetDeleteV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted partner asset due to a user delete', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
const { auth, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await ctx.newAsset({ ownerId: user2.id });
await userRepo.delete({ id: user2.id }, true);
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const assetRepo = getRepository('asset');
await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
const partnerRepo = getRepository('partner');
const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
await partnerRepo.create(partner);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
const { auth, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newAsset({ ownerId: user2.id });
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
await partnerRepo.remove(partner);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync an asset or asset delete for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await assetRepo.remove(asset);
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
});
it('should not sync an asset or asset delete for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await assetRepo.remove(asset);
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill partner assets when a partner shared their library with you', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser3);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
await assetRepo.create(assetUser2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
expect(backfillResponse).toHaveLength(2);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
await sut.setAcks(auth, { acks: [backfillAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
await assetRepo.create(assetUser2);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
await assetRepo.create(asset2User3);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const { asset: asset2User3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(newResponse).toHaveLength(3);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(backfillResponse).toHaveLength(3);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
const partnerAssetAck = backfillResponse[2].ack;
await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
});
@@ -1,75 +1,58 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.PartnerV1, () => {
describe(SyncEntityType.PartnerV1, () => {
it('should detect and sync the first partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const user1 = auth.user;
const userRepo = getRepository('user');
const partnerRepo = getRepository('partner');
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
await partnerRepo.remove(partner);
const response = await testSync(auth, [SyncRequestType.PartnersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
@@ -84,27 +67,18 @@ describe.concurrent(SyncEntityType.PartnerV1, () => {
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should detect and sync a partner share both to and from another user', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = getRepository('partner');
const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
const response = await testSync(auth, [SyncRequestType.PartnersV1]);
const { user: user2 } = await ctx.newUser();
const { partner: partner1 } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const { partner: partner2 } = await ctx.newPartner({ sharedById: user1.id, sharedWithId: user2.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
expect.arrayContaining([
@@ -129,93 +103,80 @@ describe.concurrent(SyncEntityType.PartnerV1, () => {
]),
);
await sut.setAcks(auth, { acks: [response[1].ack] });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should sync a partner and then an update to that same partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
const updated = await partnerRepo.update(
{ sharedById: partner.sharedById, sharedWithId: partner.sharedWithId },
{ inTimeline: true },
);
const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(updatedSyncResponse).toHaveLength(1);
expect(updatedSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: updated.inTimeline,
sharedById: updated.sharedById,
sharedWithId: updated.sharedWithId,
},
type: 'PartnerV1',
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: updated.inTimeline,
sharedById: updated.sharedById,
sharedWithId: updated.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should not sync a partner or partner delete for an unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = await userRepo.create(mediumFactory.userInsert());
const user3 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id });
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user3.id });
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
await partnerRepo.remove(partner);
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should not sync a partner delete after a user is deleted', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = await userRepo.create(mediumFactory.userInsert());
const userRepo = ctx.get(UserRepository);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await userRepo.delete({ id: user2.id }, true);
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
});
@@ -9,4 +9,11 @@ describe('types', () => {
expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length);
});
it('should ensure album follows albums assets', () => {
const albumIndex = SYNC_TYPES_ORDER.indexOf(SyncRequestType.AlbumsV1);
const albumAssetsIndex = SYNC_TYPES_ORDER.indexOf(SyncRequestType.AlbumAssetsV1);
expect(albumIndex).toBeGreaterThan(albumAssetsIndex);
});
});
+71 -90
View File
@@ -1,36 +1,35 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.UserV1, () => {
describe(SyncEntityType.UserV1, () => {
it('should detect and sync the first user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const userRepo = getRepository('user');
const userRepo = ctx.get(UserRepository);
const user = await userRepo.get(auth.user.id, { withDeleted: false });
if (!user) {
expect.fail('First user should exist');
}
const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
@@ -43,21 +42,17 @@ describe.concurrent(SyncEntityType.UserV1, () => {
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should detect and sync a soft deleted user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const deletedAt = new Date().toISOString();
const deletedUser = mediumFactory.userInsert({ deletedAt });
const deleted = await getRepository('user').create(deletedUser);
const { user: deleted } = await ctx.newUser({ deletedAt });
const response = await testSync(auth, [SyncRequestType.UsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
@@ -85,95 +80,81 @@ describe.concurrent(SyncEntityType.UserV1, () => {
]),
);
const acks = [response[1].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const userRepo = getRepository('user');
const user = mediumFactory.userInsert();
await userRepo.create(user);
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
await userRepo.delete({ id: user.id }, true);
const response = await testSync(auth, [SyncRequestType.UsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
userId: user.id,
},
type: 'UserDeleteV1',
expect(response).toEqual([
{
ack: expect.any(String),
data: {
userId: user.id,
},
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
type: 'UserV1',
type: 'UserDeleteV1',
},
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
]),
);
type: 'UserV1',
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should sync a user and then an update to that same user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
const userRepo = ctx.get(UserRepository);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
type: 'UserV1',
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
]),
);
type: 'UserV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
const userRepo = getRepository('user');
const updated = await userRepo.update(auth.user.id, { name: 'new name' });
const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(updatedSyncResponse).toHaveLength(1);
expect(updatedSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: updated.name,
},
type: 'UserV1',
const newResponse = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: updated.name,
},
]),
);
type: 'UserV1',
},
]);
});
});
@@ -1,38 +0,0 @@
import { PersonRepository } from 'src/repositories/person.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepository>> => {
return {
reassignFaces: vitest.fn(),
unassignFaces: vitest.fn(),
delete: vitest.fn(),
deleteFaces: vitest.fn(),
getAllFaces: vitest.fn(),
getAll: vitest.fn(),
getAllForUser: vitest.fn(),
getAllWithoutFaces: vitest.fn(),
getFaces: vitest.fn(),
getFaceById: vitest.fn(),
getFaceForFacialRecognitionJob: vitest.fn(),
getDataForThumbnailGenerationJob: vitest.fn(),
reassignFace: vitest.fn(),
getById: vitest.fn(),
getByName: vitest.fn(),
getDistinctNames: vitest.fn(),
getStatistics: vitest.fn(),
getNumberOfPeople: vitest.fn(),
create: vitest.fn(),
createAll: vitest.fn(),
refreshFaces: vitest.fn(),
update: vitest.fn(),
updateAll: vitest.fn(),
getFacesByIds: vitest.fn(),
getRandomFace: vitest.fn(),
getLatestFaceDate: vitest.fn(),
createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(),
vacuum: vitest.fn(),
};
};
+5 -2
View File
@@ -47,6 +47,7 @@ import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
@@ -67,7 +68,6 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
@@ -218,6 +218,7 @@ export type ServiceOverrides = {
stack: StackRepository;
storage: StorageRepository;
sync: SyncRepository;
syncCheckpoint: SyncCheckpointRepository;
systemMetadata: SystemMetadataRepository;
tag: TagRepository;
telemetry: TelemetryRepository;
@@ -278,7 +279,7 @@ export const newTestService = <T extends BaseService>(
notification: automock(NotificationRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: newPersonRepositoryMock(),
person: automock(PersonRepository, { strict: false }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays
@@ -288,6 +289,7 @@ export const newTestService = <T extends BaseService>(
stack: automock(StackRepository),
storage: newStorageRepositoryMock(),
sync: automock(SyncRepository),
syncCheckpoint: automock(SyncCheckpointRepository),
systemMetadata: newSystemMetadataRepositoryMock(),
// systemMetadata: automock(SystemMetadataRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays
@@ -337,6 +339,7 @@ export const newTestService = <T extends BaseService>(
overrides.stack || (mocks.stack as As<StackRepository>),
overrides.storage || (mocks.storage as As<StorageRepository>),
overrides.sync || (mocks.sync as As<SyncRepository>),
overrides.syncCheckpoint || (mocks.syncCheckpoint as As<SyncCheckpointRepository>),
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
overrides.tag || (mocks.tag as As<TagRepository>),
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),