From cd288533a1f9459af8f04dfb34f80b25354cd5c5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 21 May 2025 15:35:32 -0400 Subject: [PATCH] feat: sync albums and album users (#18377) --- mobile/openapi/README.md | Bin 35685 -> 35877 bytes mobile/openapi/lib/api.dart | Bin 12730 -> 12886 bytes mobile/openapi/lib/api_client.dart | Bin 32287 -> 32623 bytes .../lib/model/sync_album_delete_v1.dart | Bin 0 -> 2863 bytes .../lib/model/sync_album_user_delete_v1.dart | Bin 0 -> 3162 bytes .../openapi/lib/model/sync_album_user_v1.dart | Bin 0 -> 5851 bytes mobile/openapi/lib/model/sync_album_v1.dart | Bin 0 -> 5008 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 3926 -> 4522 bytes .../openapi/lib/model/sync_request_type.dart | Bin 3332 -> 3614 bytes open-api/immich-openapi-specs.json | 109 ++- open-api/typescript-sdk/src/fetch-client.ts | 10 +- server/package.json | 1 + server/src/bin/migrations.ts | 1 + server/src/db.d.ts | 18 + server/src/dtos/sync.dto.ts | 39 +- server/src/enum.ts | 7 + server/src/queries/album.user.repository.sql | 4 +- server/src/queries/sync.repository.sql | 95 ++ .../src/repositories/album-user.repository.ts | 10 +- server/src/repositories/sync.repository.ts | 109 ++- server/src/schema/functions.ts | 48 + server/src/schema/index.ts | 13 +- .../1747664684909-AddAlbumAuditTables.ts | 96 ++ server/src/schema/tables/album-audit.table.ts | 17 + .../schema/tables/album-user-audit.table.ts | 17 + server/src/schema/tables/album-user.table.ts | 32 +- server/src/schema/tables/album.table.ts | 9 + server/src/services/sync.service.ts | 40 +- .../decorators/after-insert.decorator.ts | 8 + server/src/sql-tools/public_api.ts | 1 + server/test/medium.factory.ts | 87 +- .../specs/services/sync.service.spec.ts | 910 ------------------ .../medium/specs/sync/sync-album-user.spec.ts | 269 ++++++ .../test/medium/specs/sync/sync-album.spec.ts | 220 +++++ .../medium/specs/sync/sync-asset-exif.spec.ts | 100 ++ .../test/medium/specs/sync/sync-asset.spec.ts | 130 +++ .../sync/sync-partner-asset-exif.spec.ts | 129 +++ .../specs/sync/sync-partner-asset.spec.ts | 208 ++++ .../medium/specs/sync/sync-partner.spec.ts | 221 +++++ .../test/medium/specs/sync/sync-types.spec.ts | 12 + .../test/medium/specs/sync/sync-user.spec.ts | 179 ++++ 41 files changed, 2215 insertions(+), 934 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_album_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_album_user_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_album_user_v1.dart create mode 100644 mobile/openapi/lib/model/sync_album_v1.dart create mode 100644 server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts create mode 100644 server/src/schema/tables/album-audit.table.ts create mode 100644 server/src/schema/tables/album-user-audit.table.ts create mode 100644 server/src/sql-tools/from-code/decorators/after-insert.decorator.ts delete mode 100644 server/test/medium/specs/services/sync.service.spec.ts create mode 100644 server/test/medium/specs/sync/sync-album-user.spec.ts create mode 100644 server/test/medium/specs/sync/sync-album.spec.ts create mode 100644 server/test/medium/specs/sync/sync-asset-exif.spec.ts create mode 100644 server/test/medium/specs/sync/sync-asset.spec.ts create mode 100644 server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts create mode 100644 server/test/medium/specs/sync/sync-partner-asset.spec.ts create mode 100644 server/test/medium/specs/sync/sync-partner.spec.ts create mode 100644 server/test/medium/specs/sync/sync-types.spec.ts create mode 100644 server/test/medium/specs/sync/sync-user.spec.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2c5dea7f19cde6a18eab16c22acc41c6aeb731c2..d2cae47fb5c8abc046bac812a8b8d4f9331635b2 100644 GIT binary patch delta 159 zcmaDljcMr&rVaNiWO9;9b6rw%QcF_93}ZD?@{{%TgDdlr9YNxgH&%)&f?1)(sYN(d n#aBtX!c}3K0@531sF$0f$)%vH5DhmSQ{`mFN|DWvE1HA=ve-I2 delta 14 WcmZ2FgX!rsrVaNiHqWnY69ND?mIo&Q diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 541614ca553dea340c121494b910ea226f9d60e0..aa8ae348aa40734f226aa180f892d4c04de867ac 100644 GIT binary patch delta 57 zcmdm$d@W^zj}l8xQfcnw03{Vp5F@^{IJF4GV~6r4^D4`+CW6)QE6Z&5R?6oG0BLy> A#{d8T delta 16 XcmcbXvMYImkJ4lTW!BAal`{DOK92@_ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 540dc11300940ab577aa87a27d4e2181eb291b0c..a1240c800c9e5e813cbeafd22ad55369ff8eba3a 100644 GIT binary patch delta 113 zcmbRLhw=SC#tr)6TscXlxh|xBnM#t diff --git a/mobile/openapi/lib/model/sync_album_delete_v1.dart b/mobile/openapi/lib/model/sync_album_delete_v1.dart new file mode 100644 index 0000000000000000000000000000000000000000..ae5ba3da5d1bfd19a4d73505799b04601110cdb2 GIT binary patch literal 2863 zcmbVOZExE)5dQ98aVdsG0aSV2ry`lX21_!eFEP-11qQeRoGn zmM3S;79fd49`EIOo;zwd91MqW_4{=4^0%A&n~zu1n`^kedw&zd^#txGQ}{5szPtV7 z1kK3uO)iWZ{gk|VJ)l>$Dz%a6Qk!(C3V8u_SzDQ>vXC2Dy102)n@SsZ^n@t3VrSE; zcBzqn)ym-UQf%;VB@F&|(pYqE?D6B7F0B)#OI3~m)lx~r$L-Nzg({^@3tg_z%&kS4 zzWy=I*237q0K+-ZOVE`pMWqt_y&Md(LRbr*c4c-|Eb7fQR_5gASMV?Z7$O26rK?S8 zK|m?DFzXr4L2-haOz=c+J0d>-P37R$$|iy$-Q_WEJ)UC@c+MGBt{`mi(i{C%8iQ5i z_Co7|@;1V01EWziS$vThR^!&voWp1g9-T}S5k-ibop0a#6R2Y~o}O}Y?4?Nxy+4_T zDjlq(gSD{hUv(}qFgs|4Li#9WCao1_NBkmXYiX5bvnZ68H0OQo-9iHnPWy+5Ydh}u+Vu7 zWnG{wNCR7CS$`cSDkGQJ1l&KS&QLfuB}t{&PEKVhcHUa&#Ef6)YxfsUg24-5`s5lU z>uP00F0Y-AIKvoT^q6{q98&{e?%s%$h}kzj01o(IKS?I6>oF5~wfJtLp6n+aaVW9~ ztX;rHRG$gZ$#S}@WNmKlHxw}T7`IncLR#c{F!g{BLAB@fWW-wR`lOjhg(M=+J@cKD z9$o4e@^gT7IOR0e6*XX8K=Z_2xX%I+-Jb8IJ|+PirK8E!1A|tKIFI4D55c&Z3siH1 zJVyRk;Ms2EiFr!WI4!~@djaiznBtX$H!t0=esUg=h^uDT(3sGhlC~*_@bo1;gHHC8 zuwjRs@6d`+&HTYX<&LHzpZB`lv)QnEz%3qa+X(B%R~lvvllB+dR=G7UQ4`Ts-Q)A6 zx5Hy$P~vn)d-`Z0cPOVLsvspDX;sz+?H$!e49{yM=O;(AT>%5Zn3eC)#)%nX` z^J2}E9t<#@0l5U(@R~Iu!+);^gQ8+u!-rj6oL1NE=A-6nirpFh=>^;j07i(ydv01) zYhY0GEzG0F3lO%@f@kOzHAyHQKtKV^TIh^X){8QY93j%mo!lN{r5M#HQLn(e^j|qs z`{uR$%9TR2(BWFjiuzT|NCo3@GQ0lF3q--JxjKgN1RSabs}iav4JY5exnCge!{*sD zTd|E4n!pU9I5;?W<5a{1!K5leRKQDmJilqu7*D0aDmo++5BMU%Gg^4w4Llo#5N## z-IAdGySRbXCAewVAdH5utZKQ2rIH&TaBg4WJS@>*NK-RML>4L3ZG~zi>1~C!W74Cy zQGAIrYsX!+1?pAjq~si@--sf|MX{D8(d}NuN9+$Kd6WFlk6M;KhUhNw0 z15XmtObUBR93Q#eLB5Xf2VK2P_MX1mTU&&(+s?Ap3B_anR#1nrT{(dY&(2W=2jIJ6 zZ|ojgDY<3W70xy+S*3Y;8_;xEDz4kg*l~zvm@oF6P+X#f3c^)mRh*JKCD=&jXQ`LChU(E+ci@zOV(46~Vn%$I;m0)sIVyo*K$nSvn2%JRQi|M4i@yEJq;V)FVKq_jfd#DK zrzBXru_NieA;DebZyP7jT?}&kHsck9t{QI99}tRf)h^~ zu6`BchX#G<7DpjbUJ;d$vhEpUs}(wr>LrE8jWTBLE9qzX{*Rs>DV4{_&ad6xns`uE z*xVuKQDQQptpl+quC$xv{Yf~z&}G|}B9QGOb41`JtmQ7+dU@Pn!` uEzJjmHy>t~bT9Q{uwU8kBKkM^cDInpjm4z9a=5R{(0?=N2`?wOf4&1#ru|a@ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/sync_album_user_v1.dart b/mobile/openapi/lib/model/sync_album_user_v1.dart new file mode 100644 index 0000000000000000000000000000000000000000..c2b8ed7f48d3c75bb8655ef885ede61f34bef2e3 GIT binary patch literal 5851 zcmd5=ZExeo5&rI9F&7+S8CB?}p8_Y6W7NLJ*TfF)d;tnU5Z2_%Vvbs>yW|PN#s9s} z%q%I2mh5YRqCMa&X*q8@^UNDhPmfMd>EgrP?KgkBy19CHad&k|Z?1p78q(!0-Q3>M zFSnQ1Z~pNLWK8*EW_&gJOY-e^NBlE?aHY4idFf~Kys&3fyE@oxW(&Ktt{OJK^X01a z)m{!$tj#9OR&_P=_Mdg`!S2iwe^xDh+RaWHA zhFMX%2ar_@<7VIed6q7W4@XB(S5Y~qa%G)awo)} zJ|7XC;{NY!ReKl6kh5!=^vI@^i*czQWsovB*nAu+7uIvehJ2CcKH4Z-*ggAO=a*pLSy8$>@Q4gU%KNguE+Pfyi~ z?fDaD#_&|7ei?}Wj(gykM6ILO4I<>~qt~nyStT(az1Gj^u_*b)tbS0LL&`Qtp*$T^ zRlW_SgAzsCJa?v`-*H4EdP4pA@q4)%3B|-8N9D&;Ph52agQle&ebd<1gi5dZSxVo7FW2UtD-g|15$y1>iGcdw z+fWykWK>V~WV($YOXL8u5vXp0_#_WEWp%Y&Rhvd6f{G$Lh?2`SdWev2pEqHbctdON zLR&w>?C2Q|&K5|cM}R58A9<8x?z*5yJs(vTcVAXKF|@0?LPn#?dA zUIqMEXU|Wpyt_;g#ku;(7*_KlAMAG zezF%AL;A7=h0ZQw_G|i2aPx(b_!4-<0^CXd7tw=j?MAp4hJ?e@I<5nvVVj@ZOdYn! z+LPwc)B(gP-7Rbz`*wl_%mT&)gBaI<#l0o8&;YfZ0fyebzrEo(IEG&OYCHb^oq5}l zWc*pY(as;*VYFey@39B)FH9|{F#9Le^jcbY{Q+SM0l}o$4x1Qx|6^Mgj%u*b8 zNASH<X%I(@>1$dTQiI_Y3g7b~5 zgu%46Dp4@Gmx_Iu*qpiqK(3e5{sz@pHGKk{$j*@2?YRxhcmnN2dKCqY8phwzmhm@U z?SO#8^BWeP13Ww_-Ff2z(s*h8;3Uaxilw7%savi4ico9&e{m*hb^I@8lScXfx zq#v33ps3{e64%3?RJ^(2y=C*oaOGWo*52##0O3)TDp5U>;(MHB22mIFMlC zDp{G?XIgaII&&$xCi`78#!H!)PMCPAL&yQaTwoA>{Y%$~D5_PtzQGoO#SwkY*Jv0B z9yf`}Uh%+(E2Zygd@~Al%I_8C5VGO2!}tQf)5Q}~byu-jLO+#ji{*02V|q`u>>SVQ zjUKR9g6`i{9PwKwrgj9*{dof~XyXRSP4@m{qT?3&6ND~>K*_=lR;6^Z)b_=EfC5OW<# zG*qZT>c2C3%?{ByAQ~OlzrAb2&dSY^eUBg(wn7;_RMdLKVE2vb^ZmKUdcG}OIuHH} DH@tEc literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/sync_album_v1.dart b/mobile/openapi/lib/model/sync_album_v1.dart new file mode 100644 index 0000000000000000000000000000000000000000..8ac8246d46ab502e28bc4bac090bc4ba7d1de72d GIT binary patch literal 5008 zcmbVQZExE)5dNNDaVd=2!BnN)hoMMkx51JQ>Dm}*(*lDL7>q>6>}1I$sTxL_|GvAU zL{Tqmf_+FV9`BC4=U%1bd-47}g0neX&adFZ{OscG z--pOXoNrR0?ey39#mfU)s@uHKa=9w>a-}kP0%cwrnJ#4}H!`0ET-^z|*H3 z5-i`u32JTtYx4w<5m&fu=~5|eA>+kVMmCV2v?@W=S*{;a1R4y|3iX@mq zkNfWa6-Ny)$3l-iAmeM~Z#Rb67N2c0Il|uyQ@s4``HLFjj*tq-kdIDqqw`{ zPPhk|8Cm%ft3&OlE;s9;WU9p4Ao{>*j`O64ysCM=;B!g$JvGe7&K)4~6x`5dKv@Id zMOI1!D_v|VH47%;sIo$aF^w?`4C`ymBdQGCa(2q_1QQg;t(Gesop|cfWrBUqEU5!7 zaR{?2S>n95E>bF%ju5u9*L*xDNtHD2TD7mr?X4E6JhR1w?-s$6pn5#Pk%7XKT+iFo zEb+#*G(aokm?chxvAx`zxZ#78RTu}yO!yGm^BH*p8?mk1|AaC+f-qreH9k6YGZRS$ z8OO3;2>ki#YQL4$WoBMYz}wQH(g#H`eZy0G3ENXZ^GS1ZkH4jjvbLwdm7PWfCwh1X z&g2tl2CkSQj$KZ;VV$c|za``{`@z$y>fO3#Hys~T(iIpVHGcD{$y$0I_uJ!+$E(P^ z=g_G_sxAqfwpA{?R}_uStHca4q`a*Ws*}P+*d0%kx4PKT2#-BsC9ohx_jSCxXxGw| zndJ@>=v`blxkB0~Rp@XW_qd+K%3K!qe6zLtS{jZPjHw`np z@iJbwLW2^a$<Al(wqb-*q zRqt!3#k5k=)P@j-&X}J%k9EY%61d_4Cn7uxC-{%t)9k|XF8&(d)jn)_5=Dc~Mpc&D z5Cc@WH>aJlYa~&c(5rcX{WHgb0#$Rv`3pJe^8_ivu&I*nLolmR#IzD{M!ZBFjDrXs zmxQPzjFA2*|0hl!83K<9r>dm|3I1TTph<172GGak4i8umG?CB2CL1FkNdu{jrvw3T z3|DLEeeFMzU|1O(hz0t!#4xy#fJ2DEV=3ANy!$l2q|YpsyLmG4Us(PkSwDWy?ih(+ u)^@dj95&&Kgw3^n>Q1}$26w-T$+fk5QO&N^ve$AddeBesUidgKI1dW=pA3SbpshB{mdKmg`INlq+IRVY#i+cEhgkBkh+WnqTaU{gURqbSJbQH1(SkXMQWq!eTUNFN)7 aGdYk)6z-2~9%X*8!$69lmfG@3G6DcZ0bpqW delta 22 ecmZ3bd`)h{ZkEmaSv43p>$9hEY@RE?%Lo8wz6X{7 diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 08f977ad5762357ec1f9c30c89e9e0420c94d316..c149c329dec07373bf2f25e6b72ce2fca7a4d750 100644 GIT binary patch delta 154 zcmZpXnkTbiGxKC=W`5ShoTSp+$sd`dl|hW+Fhg}sYc2(a;*!LY%w&b+{Ji241+bdX z;?yFLIu4LJFlP<(V@U;wBv79YP#2U3lHJ_FlFL4M9v3^SBiM!lE?KBu6S+j7VmVxj NoN%)@zu=Z(1OVxBG-m(+ delta 21 dcmbOy(;~HDGxKD9mOGoZS<~1z^YijE0svK)29N*% diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2a8555f82..cdd1f0076 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12710,6 +12710,105 @@ ], "type": "object" }, + "SyncAlbumDeleteV1": { + "properties": { + "albumId": { + "type": "string" + } + }, + "required": [ + "albumId" + ], + "type": "object" + }, + "SyncAlbumUserDeleteV1": { + "properties": { + "albumId": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "albumId", + "userId" + ], + "type": "object" + }, + "SyncAlbumUserV1": { + "properties": { + "albumId": { + "type": "string" + }, + "role": { + "enum": [ + "editor", + "viewer" + ], + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "albumId", + "role", + "userId" + ], + "type": "object" + }, + "SyncAlbumV1": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isActivityEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "order": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] + }, + "ownerId": { + "type": "string" + }, + "thumbnailAssetId": { + "nullable": true, + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "createdAt", + "description", + "id", + "isActivityEnabled", + "name", + "order", + "ownerId", + "thumbnailAssetId", + "updatedAt" + ], + "type": "object" + }, "SyncAssetDeleteV1": { "properties": { "assetId": { @@ -12937,7 +13036,11 @@ "AssetExifV1", "PartnerAssetV1", "PartnerAssetDeleteV1", - "PartnerAssetExifV1" + "PartnerAssetExifV1", + "AlbumV1", + "AlbumDeleteV1", + "AlbumUserV1", + "AlbumUserDeleteV1" ], "type": "string" }, @@ -12982,7 +13085,9 @@ "AssetsV1", "AssetExifsV1", "PartnerAssetsV1", - "PartnerAssetExifsV1" + "PartnerAssetExifsV1", + "AlbumsV1", + "AlbumUsersV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c27c9bc19..bb1ba605a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3860,7 +3860,11 @@ export enum SyncEntityType { AssetExifV1 = "AssetExifV1", PartnerAssetV1 = "PartnerAssetV1", PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", - PartnerAssetExifV1 = "PartnerAssetExifV1" + PartnerAssetExifV1 = "PartnerAssetExifV1", + AlbumV1 = "AlbumV1", + AlbumDeleteV1 = "AlbumDeleteV1", + AlbumUserV1 = "AlbumUserV1", + AlbumUserDeleteV1 = "AlbumUserDeleteV1" } export enum SyncRequestType { UsersV1 = "UsersV1", @@ -3868,7 +3872,9 @@ export enum SyncRequestType { AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", PartnerAssetsV1 = "PartnerAssetsV1", - PartnerAssetExifsV1 = "PartnerAssetExifsV1" + PartnerAssetExifsV1 = "PartnerAssetExifsV1", + AlbumsV1 = "AlbumsV1", + AlbumUsersV1 = "AlbumUsersV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/package.json b/server/package.json index 681fd687d..f95817342 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", + "migrations:debug": "node ./dist/bin/migrations.js debug", "migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:create": "node ./dist/bin/migrations.js create", "migrations:run": "node ./dist/bin/migrations.js run", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 69070dc0c..b3329e633 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -125,6 +125,7 @@ const compare = async () => { const down = schemaDiff(target, source, { tables: { ignoreExtra: false }, functions: { ignoreExtra: false }, + extension: { ignoreMissing: true }, }); return { up, down }; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 943c9ddfa..af1dd964f 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -74,6 +74,20 @@ export interface Albums { updateId: Generated; } +export interface AlbumsAudit { + deletedAt: Generated; + id: Generated; + albumId: string; + userId: string; +} + +export interface AlbumUsersAudit { + deletedAt: Generated; + id: Generated; + albumId: string; + userId: string; +} + export interface AlbumsAssetsAssets { albumsId: string; assetsId: string; @@ -84,6 +98,8 @@ export interface AlbumsSharedUsersUsers { albumsId: string; role: Generated; usersId: string; + updatedAt: Generated; + updateId: Generated; } export interface ApiKeys { @@ -466,8 +482,10 @@ export interface VersionHistory { export interface DB { activity: Activity; albums: Albums; + albums_audit: AlbumsAudit; albums_assets_assets: AlbumsAssetsAssets; albums_shared_users_users: AlbumsSharedUsersUsers; + album_users_audit: AlbumUsersAudit; api_keys: ApiKeys; asset_faces: AssetFaces; asset_files: AssetFiles; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index cc11c3410..0043cfb40 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @@ -112,6 +112,34 @@ export class SyncAssetExifV1 { fps!: number | null; } +export class SyncAlbumDeleteV1 { + albumId!: string; +} + +export class SyncAlbumUserDeleteV1 { + albumId!: string; + userId!: string; +} + +export class SyncAlbumUserV1 { + albumId!: string; + userId!: string; + role!: AlbumUserRole; +} + +export class SyncAlbumV1 { + id!: string; + ownerId!: string; + name!: string; + description!: string; + createdAt!: Date; + updatedAt!: Date; + thumbnailAssetId!: string | null; + isActivityEnabled!: boolean; + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + order!: AssetOrder; +} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; @@ -123,10 +151,13 @@ export type SyncItem = { [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; + [SyncEntityType.AlbumV1]: SyncAlbumV1; + [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1; + [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; + [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; }; const responseDtos = [ - // SyncUserV1, SyncUserDeleteV1, SyncPartnerV1, @@ -134,6 +165,10 @@ const responseDtos = [ SyncAssetV1, SyncAssetDeleteV1, SyncAssetExifV1, + SyncAlbumV1, + SyncAlbumDeleteV1, + SyncAlbumUserV1, + SyncAlbumUserDeleteV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/enum.ts b/server/src/enum.ts index c9cf34383..b00b01339 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -578,6 +578,8 @@ export enum SyncRequestType { AssetExifsV1 = 'AssetExifsV1', PartnerAssetsV1 = 'PartnerAssetsV1', PartnerAssetExifsV1 = 'PartnerAssetExifsV1', + AlbumsV1 = 'AlbumsV1', + AlbumUsersV1 = 'AlbumUsersV1', } export enum SyncEntityType { @@ -594,6 +596,11 @@ export enum SyncEntityType { PartnerAssetV1 = 'PartnerAssetV1', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', + + AlbumV1 = 'AlbumV1', + AlbumDeleteV1 = 'AlbumDeleteV1', + AlbumUserV1 = 'AlbumUserV1', + AlbumUserDeleteV1 = 'AlbumUserDeleteV1', } export enum NotificationLevel { diff --git a/server/src/queries/album.user.repository.sql b/server/src/queries/album.user.repository.sql index d628e4980..08f337c15 100644 --- a/server/src/queries/album.user.repository.sql +++ b/server/src/queries/album.user.repository.sql @@ -6,7 +6,9 @@ insert into values ($1, $2) returning - * + "usersId", + "albumsId", + "role" -- AlbumUserRepository.update update "albums_shared_users_users" diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 54c1292d8..f797f5c0b 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -246,3 +246,98 @@ where and "updatedAt" < now() - interval '1 millisecond' order by "updateId" asc + +-- SyncRepository.getAlbumDeletes +select + "id", + "albumId" +from + "albums_audit" +where + "userId" = $1 + and "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 +select + "id", + "userId", + "albumId" +from + "album_users_audit" +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.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 diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index f363f2e91..ad7ba8d6c 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, Selectable, Updateable } from 'kysely'; +import { Insertable, Kysely, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { AlbumsSharedUsersUsers, DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -15,8 +15,12 @@ export class AlbumUserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) - create(albumUser: Insertable): Promise> { - return this.db.insertInto('albums_shared_users_users').values(albumUser).returningAll().executeTakeFirstOrThrow(); + create(albumUser: Insertable) { + return this.db + .insertInto('albums_shared_users_users') + .values(albumUser) + .returning(['usersId', 'albumsId', 'role']) + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] }) diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index f0c535ecf..43fd73274 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { SyncEntityType } from 'src/enum'; import { SyncAck } from 'src/types'; -type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit'; -type upsertTables = 'users' | 'partners' | 'assets' | 'exif'; +type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit'; +type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users'; @Injectable() export class SyncRepository { @@ -110,7 +110,6 @@ export class SyncRepository { .selectFrom('assets_audit') .select(['id', 'assetId']) .where('ownerId', '=', userId) - .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) .$call((qb) => this.auditTableFilters(qb, ack)) .stream(); } @@ -154,19 +153,115 @@ export class SyncRepository { .stream(); } - private auditTableFilters, D>(qb: SelectQueryBuilder, ack?: SyncAck) { - const builder = qb as SelectQueryBuilder; + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('albums_audit') + .select(['id', 'albumId']) + .where('userId', '=', userId) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('albums') + .distinctOn(['albums.id', 'albums.updateId']) + .where('albums.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('albums.updateId', '>', ack!.updateId)) + .orderBy('albums.updateId', 'asc') + .leftJoin('albums_shared_users_users as album_users', 'albums.id', 'album_users.albumsId') + .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) + .select([ + 'albums.id', + 'albums.ownerId', + 'albums.albumName as name', + 'albums.description', + 'albums.createdAt', + 'albums.updatedAt', + 'albums.albumThumbnailAssetId as thumbnailAssetId', + 'albums.isActivityEnabled', + 'albums.order', + 'albums.updateId', + ]) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumUserDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('album_users_audit') + .select(['id', 'userId', '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 }) + getAlbumUserUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('albums_shared_users_users') + .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', + ]) + .where('albums_shared_users_users.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId)) + .orderBy('albums_shared_users_users.updateId', 'asc') + .where((eb) => + eb( + 'albums_shared_users_users.albumsId', + '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), + ), + ), + ), + ) + .stream(); + } + + private auditTableFilters, D>(qb: SelectQueryBuilder, ack?: SyncAck) { + const builder = qb as SelectQueryBuilder; return builder .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) .orderBy('id', 'asc') as SelectQueryBuilder; } - private upsertTableFilters, D>( + private upsertTableFilters, D>( qb: SelectQueryBuilder, ack?: SyncAck, ) { - const builder = qb as SelectQueryBuilder; + const builder = qb as SelectQueryBuilder; return builder .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 65ad2b72d..a03f715bf 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({ synchronize: false, }); +export const album_user_after_insert = registerFunction({ + name: 'album_user_after_insert', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) + WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows); + RETURN NULL; + END`, + synchronize: false, +}); + export const updated_at = registerFunction({ name: 'updated_at', returnType: 'TRIGGER', @@ -114,3 +127,38 @@ export const assets_delete_audit = registerFunction({ END`, synchronize: false, }); + +export const albums_delete_audit = registerFunction({ + name: 'albums_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO albums_audit ("albumId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const album_users_delete_audit = registerFunction({ + name: 'album_users_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO albums_audit ("albumId", "userId") + SELECT "albumsId", "usersId" + FROM OLD; + + IF pg_trigger_depth() = 1 THEN + INSERT INTO album_users_audit ("albumId", "userId") + SELECT "albumsId", "usersId" + FROM OLD; + END IF; + + RETURN NULL; + END`, + synchronize: false, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 735dfd3ae..d2f8d80af 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -1,5 +1,8 @@ import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { + album_user_after_insert, + album_users_delete_audit, + albums_delete_audit, assets_delete_audit, f_concat_ws, f_unaccent, @@ -11,6 +14,8 @@ import { } from 'src/schema/functions'; import { ActivityTable } from 'src/schema/tables/activity.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'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { APIKeyTable } from 'src/schema/tables/api-key.table'; @@ -45,15 +50,16 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; -import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; +import { Database, Extensions } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) -@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) @Database({ name: 'immich' }) export class ImmichDatabase { tables = [ ActivityTable, AlbumAssetTable, + AlbumAuditTable, + AlbumUserAuditTable, AlbumUserTable, AlbumTable, APIKeyTable, @@ -99,6 +105,9 @@ export class ImmichDatabase { users_delete_audit, partners_delete_audit, assets_delete_audit, + albums_delete_audit, + album_user_after_insert, + album_users_delete_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; diff --git a/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts b/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts new file mode 100644 index 000000000..25ccfee71 --- /dev/null +++ b/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts @@ -0,0 +1,96 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION album_user_after_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) + WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows); + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION albums_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO albums_audit ("albumId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_users_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO albums_audit ("albumId", "userId") + SELECT "albumsId", "usersId" + FROM OLD; + + IF pg_trigger_depth() = 1 THEN + INSERT INTO album_users_audit ("albumId", "userId") + SELECT "albumsId", "usersId" + FROM OLD; + END IF; + + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "albums_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); + await sql`CREATE TABLE "album_users_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); + await sql`ALTER TABLE "albums_audit" ADD CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "album_users_audit" ADD CONSTRAINT "PK_f479a2e575b7ebc9698362c1688" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "albums_shared_users_users" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`ALTER TABLE "albums_shared_users_users" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`CREATE INDEX "IDX_album_users_update_id" ON "albums_shared_users_users" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_albums_audit_album_id" ON "albums_audit" ("albumId")`.execute(db); + await sql`CREATE INDEX "IDX_albums_audit_user_id" ON "albums_audit" ("userId")`.execute(db); + await sql`CREATE INDEX "IDX_albums_audit_deleted_at" ON "albums_audit" ("deletedAt")`.execute(db); + await sql`CREATE INDEX "IDX_album_users_audit_album_id" ON "album_users_audit" ("albumId")`.execute(db); + await sql`CREATE INDEX "IDX_album_users_audit_user_id" ON "album_users_audit" ("userId")`.execute(db); + await sql`CREATE INDEX "IDX_album_users_audit_deleted_at" ON "album_users_audit" ("deletedAt")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "albums_delete_audit" + AFTER DELETE ON "albums" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION albums_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_users_delete_audit" + AFTER DELETE ON "albums_shared_users_users" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION album_users_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_after_insert" + AFTER INSERT ON "albums_shared_users_users" + REFERENCING NEW TABLE AS "inserted_rows" + FOR EACH STATEMENT + EXECUTE FUNCTION album_user_after_insert();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_users_updated_at" + BEFORE UPDATE ON "albums_shared_users_users" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "albums_delete_audit" ON "albums";`.execute(db); + await sql`DROP TRIGGER "album_users_delete_audit" ON "albums_shared_users_users";`.execute(db); + await sql`DROP TRIGGER "album_user_after_insert" ON "albums_shared_users_users";`.execute(db); + await sql`DROP INDEX "IDX_albums_audit_album_id";`.execute(db); + await sql`DROP INDEX "IDX_albums_audit_user_id";`.execute(db); + await sql`DROP INDEX "IDX_albums_audit_deleted_at";`.execute(db); + await sql`DROP INDEX "IDX_album_users_audit_album_id";`.execute(db); + await sql`DROP INDEX "IDX_album_users_audit_user_id";`.execute(db); + await sql`DROP INDEX "IDX_album_users_audit_deleted_at";`.execute(db); + await sql`ALTER TABLE "albums_audit" DROP CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b";`.execute(db); + await sql`ALTER TABLE "album_users_audit" DROP CONSTRAINT "PK_f479a2e575b7ebc9698362c1688";`.execute(db); + await sql`DROP TABLE "albums_audit";`.execute(db); + await sql`DROP TABLE "album_users_audit";`.execute(db); + await sql`DROP FUNCTION album_user_after_insert;`.execute(db); + await sql`DROP FUNCTION albums_delete_audit;`.execute(db); + await sql`DROP FUNCTION album_users_delete_audit;`.execute(db); +} diff --git a/server/src/schema/tables/album-audit.table.ts b/server/src/schema/tables/album-audit.table.ts new file mode 100644 index 000000000..66b70654e --- /dev/null +++ b/server/src/schema/tables/album-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Table } from 'src/sql-tools'; + +@Table('albums_audit') +export class AlbumAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: string; + + @Column({ type: 'uuid', indexName: 'IDX_albums_audit_album_id' }) + albumId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_albums_audit_user_id' }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_albums_audit_deleted_at' }) + deletedAt!: Date; +} diff --git a/server/src/schema/tables/album-user-audit.table.ts b/server/src/schema/tables/album-user-audit.table.ts new file mode 100644 index 000000000..46ad6b682 --- /dev/null +++ b/server/src/schema/tables/album-user-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Table } from 'src/sql-tools'; + +@Table('album_users_audit') +export class AlbumUserAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: string; + + @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_album_id' }) + albumId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_user_id' }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_users_audit_deleted_at' }) + deletedAt!: Date; +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 8bd05df2e..276efd126 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -1,12 +1,36 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumUserRole } from 'src/enum'; +import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { UserTable } from 'src/schema/tables/user.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; +import { + AfterDeleteTrigger, + AfterInsertTrigger, + Column, + ForeignKeyColumn, + Index, + Table, + UpdateDateColumn, +} from 'src/sql-tools'; @Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' }) // Pre-existing indices from original album <--> user ManyToMany mapping @Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] }) @Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] }) +@UpdatedAtTrigger('album_users_updated_at') +@AfterInsertTrigger({ + name: 'album_user_after_insert', + scope: 'statement', + referencingNewTableAs: 'inserted_rows', + function: album_user_after_insert, +}) +@AfterDeleteTrigger({ + name: 'album_users_delete_audit', + scope: 'statement', + function: album_users_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) export class AlbumUserTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', @@ -26,4 +50,10 @@ export class AlbumUserTable { @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) role!: AlbumUserRole; + + @UpdateIdColumn({ indexName: 'IDX_album_users_update_id' }) + updateId?: string; + + @UpdateDateColumn() + updatedAt!: Date; } diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index 428947fa5..5d02cc9f2 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -1,8 +1,10 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetOrder } from 'src/enum'; +import { albums_delete_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, CreateDateColumn, DeleteDateColumn, @@ -14,6 +16,13 @@ import { @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) @UpdatedAtTrigger('albums_updated_at') +@AfterDeleteTrigger({ + name: 'albums_delete_audit', + scope: 'statement', + function: albums_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) export class AlbumTable { @PrimaryGeneratedColumn() id!: string; diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index bd3c09098..d6cbc17a2 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -24,13 +24,14 @@ import { fromAck, serialize } from 'src/utils/sync'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export const SYNC_TYPES_ORDER = [ - // SyncRequestType.UsersV1, SyncRequestType.PartnersV1, SyncRequestType.AssetsV1, SyncRequestType.AssetExifsV1, SyncRequestType.PartnerAssetsV1, SyncRequestType.PartnerAssetExifsV1, + SyncRequestType.AlbumsV1, + SyncRequestType.AlbumUsersV1, ]; const throwSessionRequired = () => { @@ -206,6 +207,43 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.AlbumsV1: { + const deletes = this.syncRepository.getAlbumDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AlbumDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.AlbumV1, updateId, data })); + } + + break; + } + + case SyncRequestType.AlbumUsersV1: { + const deletes = this.syncRepository.getAlbumUserDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AlbumUserDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getAlbumUserUpserts( + auth.user.id, + checkpointMap[SyncEntityType.AlbumUserV1], + ); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.AlbumUserV1, updateId, data })); + } + + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; diff --git a/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts b/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts new file mode 100644 index 000000000..103d59b4f --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts @@ -0,0 +1,8 @@ +import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; + +export const AfterInsertTrigger = (options: Omit) => + TriggerFunction({ + timing: 'after', + actions: ['insert'], + ...options, + }); diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts index b41cce4ab..c7a3023a4 100644 --- a/server/src/sql-tools/public_api.ts +++ b/server/src/sql-tools/public_api.ts @@ -1,6 +1,7 @@ export { schemaDiff } from 'src/sql-tools/diff'; export { schemaFromCode } from 'src/sql-tools/from-code'; export * from 'src/sql-tools/from-code/decorators/after-delete.decorator'; +export * from 'src/sql-tools/from-code/decorators/after-insert.decorator'; export * from 'src/sql-tools/from-code/decorators/before-update.decorator'; export * from 'src/sql-tools/from-code/decorators/check.decorator'; export * from 'src/sql-tools/from-code/decorators/column.decorator'; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 8b730c2b4..cab74f70f 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -4,9 +4,11 @@ import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; -import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; -import { AssetType, AssetVisibility, SourceType } from 'src/enum'; +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 { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -28,8 +30,9 @@ 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 { SyncService } from 'src/services/sync.service'; import { RepositoryInterface } from 'src/types'; -import { newDate, newEmbedding, newUuid } from 'test/small.factory'; +import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock, ServiceOverrides } from 'test/utils'; import { Mocked } from 'vitest'; @@ -39,6 +42,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas type RepositoriesTypes = { activity: ActivityRepository; album: AlbumRepository; + albumUser: AlbumUserRepository; asset: AssetRepository; assetJob: AssetJobRepository; config: ConfigRepository; @@ -76,6 +80,61 @@ export type Context = { getRepository(key: T): RepositoriesTypes[T]; }; +export type SyncTestOptions = { + db: Kysely; +}; + +export const newSyncAuthUser = () => { + const user = mediumFactory.userInsert(); + const session = mediumFactory.sessionInsert({ userId: user.id }); + + const auth = factory.auth({ + session, + user: { + id: user.id, + name: user.name, + email: user.email, + }, + }); + + return { + auth, + session, + user, + create: async (db: Kysely) => { + await new UserRepository(db).create(user); + await new SessionRepository(db).create(session); + }, + }; +}; + +export const newSyncTest = (options: SyncTestOptions) => { + const { sut, mocks, repos, getRepository } = newMediumService(SyncService, { + database: options.db, + repos: { + sync: 'real', + session: 'real', + }, + }); + + const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { + const stream = mediumFactory.syncStream(); + // Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy + await new Promise((resolve) => setTimeout(resolve, 2)); + await sut.stream(auth, stream, { types }); + + return stream.getResponse(); + }; + + return { + sut, + mocks, + repos, + getRepository, + testSync, + }; +}; + export const newMediumService = ( Service: ClassConstructor, options: { @@ -125,6 +184,14 @@ export const getRepository = (key: K, db: Kys return new ActivityRepository(db); } + case 'album': { + return new AlbumRepository(db); + } + + case 'albumUser': { + return new AlbumUserRepository(db); + } + case 'asset': { return new AssetRepository(db); } @@ -380,6 +447,19 @@ const assetInsert = (asset: Partial> = {}) => { }; }; +const albumInsert = (album: Partial> & { ownerId: string }) => { + const id = album.id || newUuid(); + const defaults: Omit, 'ownerId'> = { + albumName: 'Album', + }; + + return { + ...defaults, + ...album, + id, + }; +}; + const faceInsert = (face: Partial> & { faceId: string }) => { const defaults = { faceId: face.faceId, @@ -502,6 +582,7 @@ export const mediumFactory = { assetInsert, assetFaceInsert, assetJobStatusInsert, + albumInsert, faceInsert, personInsert, sessionInsert, diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts deleted file mode 100644 index 67cfeafdb..000000000 --- a/server/test/medium/specs/services/sync.service.spec.ts +++ /dev/null @@ -1,910 +0,0 @@ -import { AuthDto } from 'src/dtos/auth.dto'; -import { SyncEntityType, SyncRequestType } from 'src/enum'; -import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service'; -import { mediumFactory, newMediumService } from 'test/medium.factory'; -import { factory } from 'test/small.factory'; -import { getKyselyDB } from 'test/utils'; - -const setup = async () => { - const db = await getKyselyDB(); - - const { sut, mocks, repos, getRepository } = newMediumService(SyncService, { - database: db, - repos: { - sync: 'real', - session: 'real', - }, - }); - - const user = mediumFactory.userInsert(); - const session = mediumFactory.sessionInsert({ userId: user.id }); - const auth = factory.auth({ - session, - user: { - id: user.id, - name: user.name, - email: user.email, - }, - }); - - await getRepository('user').create(user); - await getRepository('session').create(session); - - const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { - const stream = mediumFactory.syncStream(); - // 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(); - }; - - return { - sut, - auth, - mocks, - repos, - getRepository, - testSync, - }; -}; - -describe(SyncService.name, () => { - it('should have all the types in the ordering variable', () => { - for (const key in SyncRequestType) { - expect(SYNC_TYPES_ORDER).includes(key); - } - - expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); - }); - - describe.concurrent(SyncEntityType.UserV1, () => { - it('should detect and sync the first user', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - 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([ - { - ack: expect.any(String), - data: { - deletedAt: user.deletedAt, - email: user.email, - id: user.id, - name: user.name, - }, - type: 'UserV1', - }, - ]); - - const acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - it('should detect and sync a soft deleted user', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const deletedAt = new Date().toISOString(); - const deletedUser = mediumFactory.userInsert({ deletedAt }); - const deleted = await getRepository('user').create(deletedUser); - - const response = await testSync(auth, [SyncRequestType.UsersV1]); - - expect(response).toHaveLength(2); - expect(response).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, - type: 'UserV1', - }, - { - ack: expect.any(String), - data: { - deletedAt, - email: deleted.email, - id: deleted.id, - name: deleted.name, - }, - type: 'UserV1', - }, - ]), - ); - - const acks = [response[1].ack]; - await sut.setAcks(auth, { acks }); - const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - it('should detect and sync a deleted user', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user = mediumFactory.userInsert(); - await userRepo.create(user); - await userRepo.delete({ id: user.id }, true); - - const response = await testSync(auth, [SyncRequestType.UsersV1]); - - expect(response).toHaveLength(2); - expect(response).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - userId: user.id, - }, - 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); - }); - - it('should sync a user and then an update to that same user', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); - - 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 acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - 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', - }, - ]), - ); - }); - }); - - describe.concurrent(SyncEntityType.PartnerV1, () => { - it('should detect and sync the first partner', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const user1 = auth.user; - const userRepo = getRepository('user'); - const partnerRepo = getRepository('partner'); - - 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 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, sut, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user1 = auth.user; - const user2 = mediumFactory.userInsert(); - await userRepo.create(user2); - - const partnerRepo = getRepository('partner'); - const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); - await partnerRepo.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, sut, getRepository, testSync } = 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]); - - 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, sut, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user1 = auth.user; - const user2 = await userRepo.create(mediumFactory.userInsert()); - - const partnerRepo = getRepository('partner'); - 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 acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - 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', - }, - ]), - ); - }); - - it('should not sync a partner or partner delete for an unrelated user', async () => { - const { auth, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user2 = await userRepo.create(mediumFactory.userInsert()); - const user3 = await userRepo.create(mediumFactory.userInsert()); - - const partnerRepo = getRepository('partner'); - const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id }); - - expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); - - await partnerRepo.remove(partner); - - expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); - }); - - it('should not sync a partner delete after a user is deleted', async () => { - const { auth, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user2 = await userRepo.create(mediumFactory.userInsert()); - - const partnerRepo = getRepository('partner'); - await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); - await userRepo.delete({ id: user2.id }, true); - - expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); - }); - }); - - describe.concurrent(SyncEntityType.AssetV1, () => { - it('should detect and sync the first asset', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; - const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; - const date = new Date().toISOString(); - - const assetRepo = getRepository('asset'); - const asset = mediumFactory.assetInsert({ - ownerId: auth.user.id, - checksum: Buffer.from(checksum, 'base64'), - thumbhash: Buffer.from(thumbhash, 'base64'), - fileCreatedAt: date, - fileModifiedAt: date, - localDateTime: date, - deletedAt: null, - }); - 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, - 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, - }, - type: 'AssetV1', - }, - ]), - ); - - const acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - 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); - await assetRepo.remove(asset); - - const response = await testSync(auth, [SyncRequestType.AssetsV1]); - - expect(response).toHaveLength(1); - expect(response).toEqual( - expect.arrayContaining([ - { - 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); - }); - - 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 auth2 = factory.auth({ session, user: user2 }); - - expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); - expect(await testSync(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); - }); - }); - - describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { - it('should detect and sync the first partner asset', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - 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({ - ownerId: user2.id, - checksum: Buffer.from(checksum, 'base64'), - thumbhash: Buffer.from(thumbhash, 'base64'), - fileCreatedAt: date, - fileModifiedAt: date, - localDateTime: date, - deletedAt: null, - }); - await assetRepo.create(asset); - - const partnerRepo = getRepository('partner'); - await partnerRepo.create({ 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, - thumbhash, - checksum, - deletedAt: null, - fileCreatedAt: date, - fileModifiedAt: date, - isFavorite: false, - localDateTime: date, - type: asset.type, - visibility: asset.visibility, - }, - type: SyncEntityType.PartnerAssetV1, - }, - ]), - ); - - const acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - it('should detect and sync a deleted partner asset', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - 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 }); - await assetRepo.remove(asset); - - const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); - - expect(response).toHaveLength(1); - expect(response).toEqual( - expect.arrayContaining([ - { - 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); - }); - - 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 })); - - await userRepo.delete({ id: user2.id }, true); - - const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); - expect(response).toHaveLength(0); - }); - - 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); - - await partnerRepo.remove(partner); - - await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); - }); - - it('should not sync an asset or asset delete for own user', async () => { - const { auth, getRepository, testSync } = await setup(); - - const userRepo = getRepository('user'); - const user2 = mediumFactory.userInsert(); - await userRepo.create(user2); - - 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 assetRepo.remove(asset); - - await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); - await expect(testSync(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 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 assetRepo.remove(asset); - - await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); - await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); - }); - }); - - describe.concurrent(SyncRequestType.AssetExifsV1, () => { - it('should detect and sync the first asset exif', async () => { - const { auth, sut, getRepository, testSync } = await setup(); - - 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 acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - 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 auth2 = factory.auth({ session, user: user2 }); - await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); - await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); - }); - }); - - describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { - it('should detect and sync the first partner asset exif', async () => { - const { auth, sut, 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 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 acks = [initialSyncResponse[0].ack]; - await sut.setAcks(auth, { acks }); - - const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); - - expect(ackSyncResponse).toHaveLength(0); - }); - - it('should not sync partner 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: 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); - }); - - 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 authUser3 = factory.auth({ session, user: user3 }); - await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); - await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); - }); - }); -}); diff --git a/server/test/medium/specs/sync/sync-album-user.spec.ts b/server/test/medium/specs/sync/sync-album-user.spec.ts new file mode 100644 index 000000000..4967df526 --- /dev/null +++ b/server/test/medium/specs/sync/sync-album-user.spec.ts @@ -0,0 +1,269 @@ +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 { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncRequestType.AlbumUsersV1, () => { + it('should sync an album user with the correct properties', async () => { + const { auth, getRepository, testSync } = await setup(); + + 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: albumUser.albumsId, + role: albumUser.role, + userId: albumUser.usersId, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + }); + describe('owner', () => { + it('should detect and sync a new shared user', async () => { + const { auth, testSync, getRepository } = await setup(); + + 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: albumUser.albumsId, + role: albumUser.role, + userId: albumUser.usersId, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + }); + + it('should detect and sync an updated shared user', async () => { + const { auth, testSync, getRepository, sut } = await setup(); + + 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([]); + + await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER }); + + await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: albumUser.albumsId, + role: AlbumUserRole.VIEWER, + userId: albumUser.usersId, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + }); + + it('should detect and sync a deleted shared user', async () => { + const { auth, testSync, getRepository, sut } = await setup(); + + 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([]); + + await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id }); + + await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: albumUser.albumsId, + userId: albumUser.usersId, + }), + type: SyncEntityType.AlbumUserDeleteV1, + }, + ]); + }); + }); + + describe('shared user', () => { + it('should detect and sync a new shared user', async () => { + const { auth, testSync, getRepository } = await setup(); + + 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: albumUser.albumsId, + role: albumUser.role, + userId: albumUser.usersId, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + }); + + it('should detect and sync an updated shared user', async () => { + const { auth, testSync, getRepository, sut } = await setup(); + + 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([]); + + await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER }); + + await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album.id, + role: AlbumUserRole.VIEWER, + userId: user.id, + }), + type: SyncEntityType.AlbumUserV1, + }, + ]); + }); + + it('should detect and sync a deleted shared user', async () => { + const { auth, testSync, getRepository, sut } = await setup(); + + 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([]); + + await albumUserRepo.delete({ albumsId: album.id, usersId: user.id }); + + await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album.id, + userId: user.id, + }), + type: SyncEntityType.AlbumUserDeleteV1, + }, + ]); + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album.spec.ts b/server/test/medium/specs/sync/sync-album.spec.ts new file mode 100644 index 000000000..7ee7bf624 --- /dev/null +++ b/server/test/medium/specs/sync/sync-album.spec.ts @@ -0,0 +1,220 @@ +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 { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: album.id, + name: album.albumName, + ownerId: album.ownerId, + }), + type: SyncEntityType.AlbumV1, + }, + ]); + }); + + 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: album.id, + }), + type: SyncEntityType.AlbumV1, + }, + ]); + }); + + 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: album.id, + }), + type: SyncEntityType.AlbumV1, + }, + ]); + + await albumRepo.delete(album.id); + await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + }, + type: SyncEntityType.AlbumDeleteV1, + }, + ]); + }); + + 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 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: album.id }), + type: SyncEntityType.AlbumV1, + }, + ]); + }); + + 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 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: album.id }), + type: SyncEntityType.AlbumV1, + }, + ]); + }); + + 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 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([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: userAlbum.id }), + type: SyncEntityType.AlbumV1, + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR }); + + await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: user2Album.id }), + type: SyncEntityType.AlbumV1, + }, + ]); + }); + + 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 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 }]); + + 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 albumRepo.delete(album.id); + + await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: { albumId: album.id }, + type: SyncEntityType.AlbumDeleteV1, + }, + ]); + }); + + 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 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 }]); + + 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 albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id }); + + await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: { albumId: album.id }, + type: SyncEntityType.AlbumDeleteV1, + }, + ]); + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-asset-exif.spec.ts new file mode 100644 index 000000000..9a3bcb431 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-exif.spec.ts @@ -0,0 +1,100 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.AssetExifsV1, () => { + it('should detect and sync the first asset exif', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + 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 auth2 = factory.auth({ session, user: user2 }); + await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts new file mode 100644 index 000000000..3cf6d7d30 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -0,0 +1,130 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncEntityType.AssetV1, () => { + it('should detect and sync the first asset', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ + ownerId: auth.user.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + localDateTime: date, + deletedAt: null, + }); + 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, + 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, + }, + type: 'AssetV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + 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); + await assetRepo.remove(asset); + + const response = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + 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); + }); + + 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 auth2 = factory.auth({ session, user: user2 }); + + expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); + expect(await testSync(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); + }); +}); diff --git a/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts new file mode 100644 index 000000000..8d9e6d6ac --- /dev/null +++ b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts @@ -0,0 +1,129 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { + it('should detect and sync the first partner asset exif', async () => { + const { auth, sut, 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 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync partner 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: 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); + }); + + 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 authUser3 = factory.auth({ session, user: user3 }); + await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts new file mode 100644 index 000000000..70e31eca4 --- /dev/null +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -0,0 +1,208 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { + it('should detect and sync the first partner asset', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + 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({ + ownerId: user2.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + localDateTime: date, + deletedAt: null, + }); + await assetRepo.create(asset); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ 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, + thumbhash, + checksum, + deletedAt: null, + fileCreatedAt: date, + fileModifiedAt: date, + isFavorite: false, + localDateTime: date, + type: asset.type, + visibility: asset.visibility, + }, + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner asset', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + 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 }); + await assetRepo.remove(asset); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + 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); + }); + + 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 })); + + await userRepo.delete({ id: user2.id }, true); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + expect(response).toHaveLength(0); + }); + + 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); + + await partnerRepo.remove(partner); + + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should not sync an asset or asset delete for own user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + 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 assetRepo.remove(asset); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(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 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 assetRepo.remove(asset); + + await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-partner.spec.ts b/server/test/medium/specs/sync/sync-partner.spec.ts new file mode 100644 index 000000000..f262eec85 --- /dev/null +++ b/server/test/medium/specs/sync/sync-partner.spec.ts @@ -0,0 +1,221 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncEntityType.PartnerV1, () => { + it('should detect and sync the first partner', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const user1 = auth.user; + const userRepo = getRepository('user'); + const partnerRepo = getRepository('partner'); + + 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 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, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user1 = auth.user; + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const partnerRepo = getRepository('partner'); + const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); + await partnerRepo.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, sut, getRepository, testSync } = 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]); + + 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, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user1 = auth.user; + const user2 = await userRepo.create(mediumFactory.userInsert()); + + const partnerRepo = getRepository('partner'); + 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + 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', + }, + ]), + ); + }); + + it('should not sync a partner or partner delete for an unrelated user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = await userRepo.create(mediumFactory.userInsert()); + const user3 = await userRepo.create(mediumFactory.userInsert()); + + const partnerRepo = getRepository('partner'); + const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id }); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + + await partnerRepo.remove(partner); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); + + it('should not sync a partner delete after a user is deleted', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = await userRepo.create(mediumFactory.userInsert()); + + const partnerRepo = getRepository('partner'); + await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + await userRepo.delete({ id: user2.id }, true); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-types.spec.ts b/server/test/medium/specs/sync/sync-types.spec.ts new file mode 100644 index 000000000..1af5a68fd --- /dev/null +++ b/server/test/medium/specs/sync/sync-types.spec.ts @@ -0,0 +1,12 @@ +import { SyncRequestType } from 'src/enum'; +import { SYNC_TYPES_ORDER } from 'src/services/sync.service'; + +describe('types', () => { + it('should have all the types in the ordering variable', () => { + for (const key in SyncRequestType) { + expect(SYNC_TYPES_ORDER).includes(key); + } + + expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); + }); +}); diff --git a/server/test/medium/specs/sync/sync-user.spec.ts b/server/test/medium/specs/sync/sync-user.spec.ts new file mode 100644 index 000000000..2cea38267 --- /dev/null +++ b/server/test/medium/specs/sync/sync-user.spec.ts @@ -0,0 +1,179 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncEntityType.UserV1, () => { + it('should detect and sync the first user', async () => { + const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); + + const userRepo = getRepository('user'); + 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([ + { + ack: expect.any(String), + data: { + deletedAt: user.deletedAt, + email: user.email, + id: user.id, + name: user.name, + }, + type: 'UserV1', + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a soft deleted user', async () => { + const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); + + const deletedAt = new Date().toISOString(); + const deletedUser = mediumFactory.userInsert({ deletedAt }); + const deleted = await getRepository('user').create(deletedUser); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + { + ack: expect.any(String), + data: { + deletedAt, + email: deleted.email, + id: deleted.id, + name: deleted.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = [response[1].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted user', async () => { + const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); + + const userRepo = getRepository('user'); + const user = mediumFactory.userInsert(); + await userRepo.create(user); + await userRepo.delete({ id: user.id }, true); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + userId: user.id, + }, + 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); + }); + + it('should sync a user and then an update to that same user', async () => { + const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + 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 acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + 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', + }, + ]), + ); + }); +});