From 92384c28de047c1b697c3f48343e137e7756a078 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 23 Jul 2025 09:59:33 -0400 Subject: [PATCH] feat: sync auth user (#20067) --- e2e/test-assets | 2 +- mobile/openapi/README.md | Bin 38829 -> 38873 bytes mobile/openapi/lib/api.dart | Bin 13990 -> 14027 bytes mobile/openapi/lib/api_client.dart | Bin 35009 -> 35089 bytes .../openapi/lib/model/sync_auth_user_v1.dart | Bin 0 -> 6701 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 9090 -> 9229 bytes .../openapi/lib/model/sync_request_type.dart | Bin 5164 -> 5310 bytes mobile/openapi/lib/model/sync_user_v1.dart | Bin 3475 -> 3888 bytes mobile/test/fixtures/sync_stream.stub.dart | 2 + open-api/immich-openapi-specs.json | 81 ++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/database.ts | 1 + server/src/dtos/sync.dto.ts | 18 ++++ server/src/enum.ts | 3 + server/src/queries/sync.repository.sql | 24 +++++ server/src/repositories/sync.repository.ts | 29 +++++- server/src/services/sync.service.ts | 10 ++ server/test/medium.factory.ts | 7 ++ .../medium/specs/sync/sync-auth-user.spec.ts | 87 ++++++++++++++++++ .../test/medium/specs/sync/sync-user.spec.ts | 43 ++------- 20 files changed, 269 insertions(+), 40 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_auth_user_v1.dart create mode 100644 server/test/medium/specs/sync/sync-auth-user.spec.ts diff --git a/e2e/test-assets b/e2e/test-assets index 18736fc27..37f60ea53 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 18736fc27a80c99c68e856cdb4f842bc81ed3445 +Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b20a0694c52c6cbb880bda4c54162955a61bfeb1..2d764aa153667a29b298c9f4889a86aead7300cb 100644 GIT binary patch delta 44 tcmZ3xp6TX#rVU!nvW}%C8KK3gMPY`q8Y%h7`uf3@dC4%r&C<=2L;#5m5cvQA delta 14 Vcmcb)o@woRrVU!no5Px$MF2E#25A5Q diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 545955a184518c713bac9fa547152f9f03708026..8b8acc0042e11d9a358caf64e6a6710f75281e91 100644 GIT binary patch delta 21 dcmZ3MdpdW+233~C(vpnHavE}**Q)*$003$Q2@C)L delta 12 UcmX?|yDWFZ2Gz}1Rlf-U04)3lga7~l diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 55d6f4108b92086be8eb994ddbabfe3ae2e07faf..d9cae66dd32d1d1655bed030592a608f6f580365 100644 GIT binary patch delta 40 ocmX>&k!j*2rVSI~I2=n$GD3?}izWvqN+CFn@oJkV#2IP<07X&{egFUf delta 14 WcmbO@iRs`(rVSI~Hb00n)B*r8&IV)v diff --git a/mobile/openapi/lib/model/sync_auth_user_v1.dart b/mobile/openapi/lib/model/sync_auth_user_v1.dart new file mode 100644 index 0000000000000000000000000000000000000000..1dab7f47e3922ca4f0429fd5142b7b89d419d469 GIT binary patch literal 6701 zcmbVQZExE)5dNND!4yXAV5-yZ!%(DC(yU2`bZs-VNr52<4922kcCzG>bOSHVf8X7a zlt@ZWvVBM_@t!-9_wtaZ-QBI-U3PeWcKYo1!S1R*4&S5u+}dq@_*87~rvNgcca{8)jxM^_YL>hO0MqS*xr^T!2%JC=N}Z zP%OAC`vh~Y|5P;w=VZaJMIS^Ky`%cDSjehRgyHEMuQ|Brq}SR~R&VN}S8HjP_XeVI zy=i;jR<+@uNPn6xeq#pv4b-MKUvI$+M1H0IaEQZJHJ zR_1`EzVi|*2pty95_*aRuj}tZDJV1W7wr-XMRD*a%k$Cc)mJfv@-cIvc38g8Y&lMO zIl|n|yyV9Z`^03YsGdHhq$d+$Er9tj94x{$(LhxD5@9GfD7O!hEQu7N(xC`13xvS` zc+>`xMD8(?V^mQ{zmden7lr;YlDk-=+&{+hq<9FJjzt=2I-o{ptg8{`Nuv@CMmk~k zAUu5UWD}$*yA}pLKS!2;{Gn864j4RFDlrvWbG62E0jPd1wA2I}1;vVURjaaO2kbf9 z3-e}s?9K16pR8V+FR@lL-lIb~)HRhPO&1`Go-vy%T><7vchn_FSKu=v-h~_O*+a?| zU5lNBh)t*Bo;e5-=0|suFej3@g`&WCCvzPAn%X{$hNM|V>*jZRE>u@4#5dp^Ku0s-2?F(0^x%olV%aFK$Mq;f%& zjAWf!%?D{T942(W1BF9bRFnB2bou3wJpF+$f3=PUSFK?ia=On<{Z7{vc-mfJX0qZX z`wBxWXC3tI-76|JIb(DVM7+LjOVwLqWaUB)gAffb!D@OrvL^4CD7A2_0H)ZajcnjF z;-xeZXPXw%AlhX#L)&9hpPT7u2Yyd<{qmRRFMJN(zrMx-8BJH$Y=g3|otIv%;Rcc< zVhc_LiJB~0B;H2wBHGw=sT&|{E+;Td=@x@;3BViK2Fk3LP!o;m!R&v|)@_1+$%~X3}W(otG=2*-X_7XX%25pmUo6V2F*~ z1(4t+Fge~S=xTZ+H8pg>1o)}llYsCw3Wa<$uFJn*Q6YdE#F<8Txh=jn~HLTkkU@T&3f{dqJn+{)( zY&(R;oU`MZMl=tK1CriL+ov&ll=g$?Ldxhnws}J%4HxdQ1z-AJW`tT9Q^Q6)#J6Ff zNOVX_UoeKJv$OS5xZUhG`j$g?w%Z94X6#tNzNXbr(8JJ@EIP`T|66q++DOAPX-6%d z(-Uy0iTv78OJ^fW4z(QPKB(xaI@EOx`k=07+8{J3uab>UXj7Dna1g4L+d6B3uA%jH ziVl;d!+Vb~6>?z|#gL>rcG8%H;C4l>TJNZqT$y>&CQfrK@m^wuMqi%xSqX!0eR+Ah zumy8VUHP@DZ}?RSO%$7PxFKbCo(3!lD4|!Sq03rq`r@)>5j2P-f=hr_mne5E^~c&A zFP3KQEdsGX`#*Aq>l^suMbK96smIb%o-kia6tHOL7UYGN$T}AT6x%I~c~Mxp3pA}$ z@oYd~}eT1W+HzzXoxXJ_@VYc*6^nY^Zk?HW5)N;pvQ1uUH z4(uupZybX$hNf{_kU&{em~Dw>O;+{PH%t^H!!A3Uize>PhjvIY4GkrS0Iwh=t_1Yl zLc_F}{G7e}eEJc;*1NdVjjs7h{|D+eP450JIV0*;?L{NJw^)q5h&X9_t8l|`Tkucz oqTpZYV0R*aR=Zb!{QB3Vaq$*9hoi%H4o0sKom&WoHXfMqU#c)%8vpA0WT9Of3oLV$_Ba8&3cL delta 26 icmdm|xkh6H2g_y;mc7iI#W<=sCJP9%ZoVe)f*AmL!3f6y diff --git a/mobile/openapi/lib/model/sync_user_v1.dart b/mobile/openapi/lib/model/sync_user_v1.dart index b9b41bb723961bd62249f258547f070ab2b896b1..c01ddcc9fc0d636b45a6002270d042627ac9c8d2 100644 GIT binary patch delta 364 zcmbO%y+Lk610zpjSz<|Ik#l}dev!`PT*f?^(Bjl0M}(lg0+Nc!*BG}-A@LP#ZBe9a znGPDEN#~X3NG#59&QD3zRM4Jm!MqgNR9h7!GjtRt-({ZQf+V7# zXsZA+Ra2pwO92eBiu3cLi`0?SsK+YUDwJeo7V9AiPCmvWCym4hD*~Bh4K}BCb397} plMRvrD+RQ`&`T@I&-DV@rh&~$O`Xlx*xJ~5kxWq6nH [type, fromAck(ack)])); const handlers: Record Promise> = { + [SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(response, checkpointMap), [SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth), @@ -169,6 +171,14 @@ export class SyncService extends BaseService { response.end(); } + private async syncAuthUsersV1(response: Writable, checkpointMap: CheckpointMap) { + const upsertType = SyncEntityType.AuthUserV1; + const upserts = this.syncRepository.authUser.getUpserts(checkpointMap[upsertType]); + for await (const { updateId, profileImagePath, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + } + } + private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) { const deleteType = SyncEntityType.UserDeleteV1; const deletes = this.syncRepository.user.getDeletes(checkpointMap[deleteType]); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index d6038b6b8..1b669e83e 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -507,7 +507,14 @@ const userInsert = (user: Partial> = {}) => { deletedAt: null, isAdmin: false, profileImagePath: '', + profileChangedAt: newDate(), shouldChangePassword: true, + storageLabel: null, + pinCode: null, + oauthId: '', + avatarColor: null, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, }; return { ...defaults, ...user, id }; diff --git a/server/test/medium/specs/sync/sync-auth-user.spec.ts b/server/test/medium/specs/sync/sync-auth-user.spec.ts new file mode 100644 index 000000000..80ce8b37f --- /dev/null +++ b/server/test/medium/specs/sync/sync-auth-user.spec.ts @@ -0,0 +1,87 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + 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.AuthUserV1, () => { + it('should detect and sync the first user', async () => { + const { auth, user, ctx } = await setup(await getKyselyDB()); + + const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: user.id, + isAdmin: user.isAdmin, + deletedAt: user.deletedAt, + name: user.name, + avatarColor: user.avatarColor, + email: user.email, + pinCode: user.pinCode, + hasProfileImage: false, + profileChangedAt: (user.profileChangedAt as Date).toISOString(), + oauthId: user.oauthId, + quotaSizeInBytes: user.quotaSizeInBytes, + quotaUsageInBytes: user.quotaUsageInBytes, + storageLabel: user.storageLabel, + }, + type: 'AuthUserV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AuthUsersV1])).resolves.toEqual([]); + }); + + it('should sync a change and then another change to that same user', async () => { + const { auth, user, ctx } = await setup(await getKyselyDB()); + + const userRepo = ctx.get(UserRepository); + + const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: user.id, + isAdmin: false, + }), + type: 'AuthUserV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await userRepo.update(user.id, { isAdmin: true }); + + const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(newResponse).toHaveLength(1); + expect(newResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: user.id, + isAdmin: true, + }), + type: 'AuthUserV1', + }, + ]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-user.spec.ts b/server/test/medium/specs/sync/sync-user.spec.ts index 24137e3ae..72661e119 100644 --- a/server/test/medium/specs/sync/sync-user.spec.ts +++ b/server/test/medium/specs/sync/sync-user.spec.ts @@ -37,6 +37,7 @@ describe(SyncEntityType.UserV1, () => { email: user.email, id: user.id, name: user.name, + avatarColor: user.avatarColor, }, type: 'UserV1', }, @@ -49,8 +50,7 @@ describe(SyncEntityType.UserV1, () => { it('should detect and sync a soft deleted user', async () => { const { auth, ctx } = await setup(await getKyselyDB()); - const deletedAt = new Date().toISOString(); - const { user: deleted } = await ctx.newUser({ deletedAt }); + const { user: deleted } = await ctx.newUser({ deletedAt: new Date().toISOString() }); const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]); @@ -59,22 +59,12 @@ describe(SyncEntityType.UserV1, () => { expect.arrayContaining([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: auth.user.id }), type: 'UserV1', }, { ack: expect.any(String), - data: { - deletedAt, - email: deleted.email, - id: deleted.id, - name: deleted.name, - }, + data: expect.objectContaining({ id: deleted.id }), type: 'UserV1', }, ]), @@ -85,7 +75,7 @@ describe(SyncEntityType.UserV1, () => { }); it('should detect and sync a deleted user', async () => { - const { auth, ctx } = await setup(await getKyselyDB()); + const { auth, user: authUser, ctx } = await setup(await getKyselyDB()); const userRepo = ctx.get(UserRepository); @@ -104,12 +94,7 @@ describe(SyncEntityType.UserV1, () => { }, { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: authUser.id }), type: 'UserV1', }, ]); @@ -119,7 +104,7 @@ describe(SyncEntityType.UserV1, () => { }); it('should sync a user and then an update to that same user', async () => { - const { auth, ctx } = await setup(await getKyselyDB()); + const { auth, user, ctx } = await setup(await getKyselyDB()); const userRepo = ctx.get(UserRepository); @@ -128,12 +113,7 @@ describe(SyncEntityType.UserV1, () => { expect(response).toEqual([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: user.id }), type: 'UserV1', }, ]); @@ -147,12 +127,7 @@ describe(SyncEntityType.UserV1, () => { expect(newResponse).toEqual([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: updated.name, - }, + data: expect.objectContaining({ id: user.id, name: updated.name }), type: 'UserV1', }, ]);