From fe702ba6d78ab828a86e3e48c951dd320590e2b1 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 3 Mar 2025 11:05:30 +0000 Subject: [PATCH] feat: partner sync (#16424) feat: partner CUD sync --- mobile/openapi/README.md | Bin 33383 -> 33479 bytes mobile/openapi/lib/api.dart | Bin 12137 -> 12214 bytes mobile/openapi/lib/api_client.dart | Bin 31020 -> 31188 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 2679 -> 2977 bytes .../lib/model/sync_partner_delete_v1.dart | Bin 0 -> 3232 bytes mobile/openapi/lib/model/sync_partner_v1.dart | Bin 0 -> 3385 bytes .../openapi/lib/model/sync_request_type.dart | Bin 2557 -> 2698 bytes 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, 375 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 66c264cd7648fc5845b4a4b5ec9600b995201792..3a3a3bc6ca47980c9da36537ba2377769ed6a03b 100644 GIT binary patch delta 82 zcmaFf!gRcqX+v5eYd~UAN#5iGg`$>VR%($;YEEiNYM7y3Zi*(Cg04bzaAjUHR4&Xg SRwE@pSzjMTY;$BGpCAAUD;>lD delta 14 VcmX@!%JjU2X+v7!=3|A7f&eq!20s7* diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 893587e7fcaea5e57b66586889abdd185406721f..04dc43f88c7b5166047b15deef878fb4b0be635f 100644 GIT binary patch delta 37 pcmaDEw=I5yx*}^qVo^!ng(~rbS*b-XsX3`7sbPkb9X%9zD)W+|!axDRSTS}e OfAWJ+wavRj3RM6_Asn9o delta 14 WcmccenQ_f0#tnNzHm8Q>s{jBzp$91d diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ed82205a37a1f2bafc676eae39be70f414b74efe..5d130f7f93ba82f871cdad82f2c0d761842412b0 100644 GIT binary patch delta 193 zcmew^vQT`(Tqb`7h2oOLlFVd<(o 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 0000000000000000000000000000000000000000..f5e10d6576f693c8919fae6aa7238e716615f6ae GIT binary patch literal 3232 zcmbVOZExE)5dQ98aVdsc!BlzOry`xbcC$02YvZELIt+#(FcNLElSz%FY8a{id+$iS zxnWjsADUX^y*~Hc@kXP;Xaq0+xS2osZFW6-_wr^oh0CkgvlymxxSrp@oB8zW@~<-# zBgq#z({}u0^7Pq&KE+xp&C`|A=}Hv*0xDSYD2eVoe^x@W0zTpqjNzX2-Ql@Q3Y^434?F+EB`SPHx}dNR>1%O+>J_x> z2Iqnf4n)fT+9kkb4t``95Z1saR#e=;N~>*cZsCrwu~sNBq~=>7LdzIrRUnOtvpZp2 zzx8-fYQDn8cQZp*84}&3)B-7v5G%42r_Dy?#0!5A>eZiJ7!LQk4nDl52vxb(Eay|J zA{S!}j{-z^gzZW#kMVmk>K@FXu>x>ZOlK*{n6dZD8u`Ze^MfW)DCo#JBblz~7qDgJ z2SRqfy4jb!wmgZ5KrtNg<=!inle*+XBY{tXN@qd}y%s$ZBq?!n#HY*k3HW`jAyB4+VpB%5ggp z=33d=wzT`kES3xhXH;8PI~@8>w7YML>Z*yJ1BoTXahDT@U!z2zcRV>8JC66Q(#t`~ zBrMN8n|hiaW9me*bA))UWff%>HGo|}^UGOr9bA{AKZF9oPl!>$qBPNZa8SzO?%t|6 z1jD2mR+M*xKYIUH_?_I?m*=5Y!C6!)47b!mRxXIAQ;@^a$QsSxJH^ofoOK{U9 zM40-dPAaOil{nE}iM~->glpjy|K@v|)K1^?v#V)S+oKAjYTHtnIeyZpbU-Qpyzg*> zz$0oWT7Cjmec~liu^z=wWaFhesz; z3k|b2F7Qh~<;k(uGHyhq^pmJv6nBcgMT?zB}GzGMY@_?H`Nz^LJ;LXP@3K&Q9U{;=@@Cr*pWRFW}?+^y2)l zBUB^JH#yUG`cv}a<%mATS}M)cmD1@-6#N7#Ss9+Eyx?0dZQOi|ZK<>!v|z=K?M+%% zHr4!}N@z5fY>R(OrtyEvwL$0F44Y?48q1_jMUEGWmEhW$o59NpA-PElCD*8CH%z84 ze@wFt(`Gcn>nx}hsFF)oiUj|?8jZ4o83R{)nf;FElIzbe;C2Ks!R$YATWM*4f#f?_ z_LQzcxWF==pmy&lB71-)Td*5p5+N62%PGEi;M8D|c_(b>X@$T^tMfIW0E#xT7nRwB_h+z8{;qEO+aad8Yk__Gk;LuWpyaC%Vp8ZoQcZ0r?J%I5ZCw`4epWto^l8_C05r;!_Ey2`M- zOiF#1;#{*LOK}8lR8C~`g8nf1*#+Q$se$r=H7Hf(TC<#=S{1qIVtCTy&J&y!G>tH3 zuRv{zQ8)GgJ)E77BxA-NARhUt<3}3}#(toYyT)DBj-(UVvhp+4alTsYOa7Wh)+{1U z#n7Y7@sS^VWFLrdVDC8_JOtJ8p$E^6NBXGg6};4SyK`yP)FPZ)YSn0c5LBz~`!Rz8 zW#`D79&A4ATQ{}WTJ6|PfwKr!tS~&jYw+-BXl|;)y1wgqUS0<@$sd790pJjIyibI= zRQ7CJ+I`~?0mgYA)qd3uN3T;2=M4oM_IPJ6nS}W7^1zVmnmCYKJ{?UR|GWO^@uLJ1 zmgk;z9ZO$P>QD01t8Z;(73CB)MLL1zm-FKKx-Ncqj`hNx5%UHiXyWt*K`mEh_iV=f z)Mw3%quyz%jW_;hv7O%3gYi%s;Z&+Os}pFKpcMBIT(@Yg@()iBIF#yr16@L2f}184 zs-sWmI}kcAiBswT5=O%ylvtvrH~gFLX|y|g&%=OVgJcg+G;P}jbH+~^nyy63Zx=gU zYEVQCL%UfI!{=THP3mEcFBIz1hDxrGJ_9Tu=G<#X#|4rP@*#%DwItryWy;V!|A#M+ zq{3rpRS)1!yMLE8m{cFz9smi!(iW{lp1AGE(3^3~sDZ}Ddpbmzz+%H&%-cN~>1A+l zV2EyobhzPwPp83aiT4cNU(GLR*RDZ#?`gYa|3+$Fjpkx4{Bp|~WmBr{ndIX|zsM4=$Ds3dQ)6_X5W0GJiQ)XuH|5#4-^NrH_h YIk7lZp-3GfyN^Sj6|7_PeGWNB0IcL3Y5)KL delta 21 ccmeAY{VTj7k!kXLrn=3#%=~Pdt2y}@0at; + 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 0628a566c..d191c82bb 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 000000000..a731e017d --- /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_shared_by_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @Index('IDX_partners_audit_shared_with_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 95168b175..483bae2fc 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 000000000..d9c9dc194 --- /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_shared_by_id" ON "partners_audit" ("sharedById") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_with_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_shared_with_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_by_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 bde4b9f10..f2c5a1fc1 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -56,4 +56,26 @@ export class SyncRepository { .orderBy(['id 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 b756c11ef..45b1b7ff8 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 983b7cbb7..a682ad48f 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 bab979410..7cd849c6f 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 fbb8ec2f6..6d94f6e03 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