From 7a69cfa1b759d47b33bae0133007c5dfd13eed7a Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 28 Feb 2025 10:52:51 +0000 Subject: [PATCH] feat: partner CUD sync --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/sync_entity_type.dart | 6 + .../lib/model/sync_partner_delete_v1.dart | 107 +++++++++++ mobile/openapi/lib/model/sync_partner_v1.dart | 115 ++++++++++++ .../openapi/lib/model/sync_request_type.dart | 3 + open-api/immich-openapi-specs.json | 41 +++- open-api/typescript-sdk/src/fetch-client.ts | 7 +- server/src/db.d.ts | 9 +- server/src/dtos/sync.dto.ts | 15 ++ server/src/entities/partner-audit.entity.ts | 19 ++ server/src/enum.ts | 3 + .../1740739778549-CreatePartnersAuditTable.ts | 38 ++++ server/src/repositories/sync.repository.ts | 22 +++ server/src/services/sync.service.ts | 20 +- server/test/factory.ts | 31 ++- server/test/medium/specs/sync.service.spec.ts | 176 ++++++++++++++++++ .../test/repositories/sync.repository.mock.ts | 2 + 19 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_partner_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_partner_v1.dart create mode 100644 server/src/entities/partner-audit.entity.ts create mode 100644 server/src/migrations/1740739778549-CreatePartnersAuditTable.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6e11640f4fe97..77bd7f3c219f4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -425,6 +425,8 @@ Class | Method | HTTP request | Description - [SyncAckDto](doc//SyncAckDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) + - [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStreamDto](doc//SyncStreamDto.md) - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 893587e7fcaea..04dc43f88c7b5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -232,6 +232,8 @@ part 'model/sync_ack_delete_dto.dart'; part 'model/sync_ack_dto.dart'; part 'model/sync_ack_set_dto.dart'; part 'model/sync_entity_type.dart'; +part 'model/sync_partner_delete_v1.dart'; +part 'model/sync_partner_v1.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stream_dto.dart'; part 'model/sync_user_delete_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7c2dc53455ff0..4d837ccb9d1dc 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,6 +520,10 @@ class ApiClient { return SyncAckSetDto.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncPartnerDeleteV1': + return SyncPartnerDeleteV1.fromJson(value); + case 'SyncPartnerV1': + return SyncPartnerV1.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStreamDto': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ed82205a37a1f..5d130f7f93ba8 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -25,11 +25,15 @@ class SyncEntityType { static const userV1 = SyncEntityType._(r'UserV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); + static const partnerV1 = SyncEntityType._(r'PartnerV1'); + static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ userV1, userDeleteV1, + partnerV1, + partnerDeleteV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -70,6 +74,8 @@ class SyncEntityTypeTypeTransformer { switch (data) { case r'UserV1': return SyncEntityType.userV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1; + case r'PartnerV1': return SyncEntityType.partnerV1; + case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_partner_delete_v1.dart b/mobile/openapi/lib/model/sync_partner_delete_v1.dart new file mode 100644 index 0000000000000..f5e10d6576f69 --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_delete_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerDeleteV1 { + /// Returns a new [SyncPartnerDeleteV1] instance. + SyncPartnerDeleteV1({ + required this.sharedById, + required this.sharedWithId, + }); + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerDeleteV1 && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerDeleteV1[sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerDeleteV1( + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_partner_v1.dart b/mobile/openapi/lib/model/sync_partner_v1.dart new file mode 100644 index 0000000000000..e551c4c83d798 --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_v1.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerV1 { + /// Returns a new [SyncPartnerV1] instance. + SyncPartnerV1({ + required this.inTimeline, + required this.sharedById, + required this.sharedWithId, + }); + + bool inTimeline; + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerV1 && + other.inTimeline == inTimeline && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerV1[inTimeline=$inTimeline, sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerV1( + inTimeline: mapValueOfType(json, r'inTimeline')!, + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index d7f1bde54cc27..c35b17dea16da 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -24,10 +24,12 @@ class SyncRequestType { String toJson() => value; static const usersV1 = SyncRequestType._(r'UsersV1'); + static const partnersV1 = SyncRequestType._(r'PartnersV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ usersV1, + partnersV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -67,6 +69,7 @@ class SyncRequestTypeTypeTransformer { if (data != null) { switch (data) { case r'UsersV1': return SyncRequestType.usersV1; + case r'PartnersV1': return SyncRequestType.partnersV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5730e41578c3b..529180197c526 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12052,13 +12052,50 @@ "SyncEntityType": { "enum": [ "UserV1", - "UserDeleteV1" + "UserDeleteV1", + "PartnerV1", + "PartnerDeleteV1" ], "type": "string" }, + "SyncPartnerDeleteV1": { + "properties": { + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "sharedById", + "sharedWithId" + ], + "type": "object" + }, + "SyncPartnerV1": { + "properties": { + "inTimeline": { + "type": "boolean" + }, + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "inTimeline", + "sharedById", + "sharedWithId" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ - "UsersV1" + "UsersV1", + "PartnersV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b2895f6f1d45b..1fb19e6eb67f9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3645,10 +3645,13 @@ export enum Error2 { } export enum SyncEntityType { UserV1 = "UserV1", - UserDeleteV1 = "UserDeleteV1" + UserDeleteV1 = "UserDeleteV1", + PartnerV1 = "PartnerV1", + PartnerDeleteV1 = "PartnerDeleteV1" } export enum SyncRequestType { - UsersV1 = "UsersV1" + UsersV1 = "UsersV1", + PartnersV1 = "PartnersV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 7fb073d8ce35d..4c75562ba16a7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -272,6 +272,13 @@ export interface NaturalearthCountries { type: string; } +export interface PartnersAudit { + deletedAt: Generated; + id: Generated; + sharedById: string; + sharedWithId: string; +} + export interface Partners { createdAt: Generated; inTimeline: Generated; @@ -316,7 +323,6 @@ export interface SessionSyncCheckpoints { updateId: Generated; } - export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; @@ -462,6 +468,7 @@ export interface DB { migrations: Migrations; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; + partners_audit: PartnersAudit; partners: Partners; person: Person; sessions: Sessions; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 0628a566cd675..d191c82bb3926 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -45,15 +45,30 @@ export class SyncUserDeleteV1 { userId!: string; } +export class SyncPartnerV1 { + sharedById!: string; + sharedWithId!: string; + inTimeline!: boolean; +} + +export class SyncPartnerDeleteV1 { + sharedById!: string; + sharedWithId!: string; +} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; + [SyncEntityType.PartnerV1]: SyncPartnerV1; + [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; }; const responseDtos = [ // SyncUserV1, SyncUserDeleteV1, + SyncPartnerV1, + SyncPartnerDeleteV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts new file mode 100644 index 0000000000000..7f002e22bf817 --- /dev/null +++ b/server/src/entities/partner-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('partners_audit') +export class PartnerAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_partners_audit_user_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @Index('IDX_partners_audit_partner_id') + @Column({ type: 'uuid' }) + sharedWithId!: string; + + @Index('IDX_partners_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 676e1d27db22a..604dac11c16a2 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -548,9 +548,12 @@ export enum DatabaseLock { export enum SyncRequestType { UsersV1 = 'UsersV1', + PartnersV1 = 'PartnersV1', } export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', + PartnerV1 = 'PartnerV1', + PartnerDeleteV1 = 'PartnerDeleteV1', } diff --git a/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts new file mode 100644 index 0000000000000..f38571b12704e --- /dev/null +++ b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePartnersAuditTable1740739778549 implements MigrationInterface { + name = 'CreatePartnersAuditTable1740739778549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_user_id" ON "partners_audit" ("sharedById") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_partner_id" ON "partners_audit" ("sharedWithId") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_deleted_at" ON "partners_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION partners_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_partner_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_user_id"`); + await queryRunner.query(`DROP TRIGGER partners_delete_audit`); + await queryRunner.query(`DROP FUNCTION partners_delete_audit`); + await queryRunner.query(`DROP TABLE "partners_audit"`); + } + +} diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index d1d0e9b8eee72..b9dea391ebf0c 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -56,4 +56,26 @@ export class SyncRepository { .orderBy(['deletedAt asc', 'userId asc']) .stream(); } + + getPartnerUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners') + .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId']) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['updateId asc']) + .stream(); + } + + getPartnerDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners_audit') + .select(['id', 'sharedById', 'sharedWithId']) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['id asc']) + .stream(); + } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index b756c11ef456b..45b1b7ff84774 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -25,6 +25,7 @@ const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; const SYNC_TYPES_ORDER = [ // SyncRequestType.UsersV1, + SyncRequestType.PartnersV1, ]; const throwSessionRequired = () => { @@ -81,8 +82,6 @@ export class SyncService extends BaseService { checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), ); - // TODO pre-filter/sort list based on optimal sync order - for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { switch (type) { case SyncRequestType.UsersV1: { @@ -99,6 +98,23 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.PartnersV1: { + const deletes = this.syncRepository.getPartnerDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerV1, updateId, data })); + } + + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; diff --git a/server/test/factory.ts b/server/test/factory.ts index 983b7cbb77ee6..a682ad48f2d4f 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,11 +1,12 @@ import { Insertable, Kysely } from 'kysely'; import { randomBytes, randomUUID } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Sessions, Users } from 'src/db'; +import { Assets, DB, Partners, Sessions, Users } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetType } from 'src/enum'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SyncRepository } from 'src/repositories/sync.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -30,6 +31,7 @@ class CustomWritable extends Writable { type Asset = Insertable; type User = Partial>; type Session = Omit, 'token'> & { token?: string }; +type Partner = Insertable; export const newUuid = () => randomUUID() as string; @@ -37,6 +39,7 @@ export class TestFactory { private assets: Asset[] = []; private sessions: Session[] = []; private users: User[] = []; + private partners: Partner[] = []; private constructor(private context: TestContext) {} @@ -100,6 +103,17 @@ export class TestFactory { }; } + static partner(partner: Partner) { + const defaults = { + inTimeline: true, + }; + + return { + ...defaults, + ...partner, + }; + } + withAsset(asset: Asset) { this.assets.push(asset); return this; @@ -115,6 +129,11 @@ export class TestFactory { return this; } + withPartner(partner: Partner) { + this.partners.push(partner); + return this; + } + async create() { for (const asset of this.assets) { await this.context.createAsset(asset); @@ -124,6 +143,10 @@ export class TestFactory { await this.context.createUser(user); } + for (const partner of this.partners) { + await this.context.createPartner(partner); + } + for (const session of this.sessions) { await this.context.createSession(session); } @@ -138,6 +161,7 @@ export class TestContext { albumRepository: AlbumRepository; sessionRepository: SessionRepository; syncRepository: SyncRepository; + partnerRepository: PartnerRepository; private constructor(private db: Kysely) { this.userRepository = new UserRepository(this.db); @@ -145,6 +169,7 @@ export class TestContext { this.albumRepository = new AlbumRepository(this.db); this.sessionRepository = new SessionRepository(this.db); this.syncRepository = new SyncRepository(this.db); + this.partnerRepository = new PartnerRepository(this.db); } static from(db: Kysely) { @@ -159,6 +184,10 @@ export class TestContext { return this.userRepository.create(TestFactory.user(user)); } + createPartner(partner: Partner) { + return this.partnerRepository.create(TestFactory.partner(partner)); + } + createAsset(asset: Asset) { return this.assetRepository.create(TestFactory.asset(asset)); } diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts index bab9794100796..7cd849c6ff911 100644 --- a/server/test/medium/specs/sync.service.spec.ts +++ b/server/test/medium/specs/sync.service.spec.ts @@ -17,6 +17,8 @@ const setup = async () => { const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { const stream = TestFactory.stream(); + // Wait for 1ms to ensure all updates are available + await new Promise((resolve) => setTimeout(resolve, 1)); await sut.stream(auth, stream, { types }); return stream.getResponse(); @@ -186,4 +188,178 @@ describe(SyncService.name, () => { ); }); }); + + describe.concurrent('partners', () => { + it('should detect and sync the first partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + await context.partnerRepository.remove(partner); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a partner share both to and from another user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner1 = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + const partner2 = await context.createPartner({ sharedById: user1.id, sharedWithId: user2.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner1.inTimeline, + sharedById: partner1.sharedById, + sharedWithId: partner1.sharedWithId, + }, + type: 'PartnerV1', + }, + { + ack: expect.any(String), + data: { + inTimeline: partner2.inTimeline, + sharedById: partner2.sharedById, + sharedWithId: partner2.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + await sut.setAcks(auth, { acks: [response[1].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a partner and then an update to that same partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.partnerRepository.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', + }, + ]), + ); + }); + + it('should not sync a partner for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + + await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(0); + }); + }); }); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts index fbb8ec2f62e78..6d94f6e03932d 100644 --- a/server/test/repositories/sync.repository.mock.ts +++ b/server/test/repositories/sync.repository.mock.ts @@ -9,5 +9,7 @@ export const newSyncRepositoryMock = (): Mocked