mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 20:07:41 +09:00 
			
		
		
		
	feat: sync albums and album users (#18377)
This commit is contained in:
		
							
								
								
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -443,6 +443,10 @@ Class | Method | HTTP request | Description | |||||||
|  - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) |  - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) | ||||||
|  - [SyncAckDto](doc//SyncAckDto.md) |  - [SyncAckDto](doc//SyncAckDto.md) | ||||||
|  - [SyncAckSetDto](doc//SyncAckSetDto.md) |  - [SyncAckSetDto](doc//SyncAckSetDto.md) | ||||||
|  |  - [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md) | ||||||
|  |  - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md) | ||||||
|  |  - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) | ||||||
|  |  - [SyncAlbumV1](doc//SyncAlbumV1.md) | ||||||
|  - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) |  - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) | ||||||
|  - [SyncAssetExifV1](doc//SyncAssetExifV1.md) |  - [SyncAssetExifV1](doc//SyncAssetExifV1.md) | ||||||
|  - [SyncAssetV1](doc//SyncAssetV1.md) |  - [SyncAssetV1](doc//SyncAssetV1.md) | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -238,6 +238,10 @@ part 'model/stack_update_dto.dart'; | |||||||
| part 'model/sync_ack_delete_dto.dart'; | part 'model/sync_ack_delete_dto.dart'; | ||||||
| part 'model/sync_ack_dto.dart'; | part 'model/sync_ack_dto.dart'; | ||||||
| part 'model/sync_ack_set_dto.dart'; | part 'model/sync_ack_set_dto.dart'; | ||||||
|  | part 'model/sync_album_delete_v1.dart'; | ||||||
|  | part 'model/sync_album_user_delete_v1.dart'; | ||||||
|  | part 'model/sync_album_user_v1.dart'; | ||||||
|  | part 'model/sync_album_v1.dart'; | ||||||
| part 'model/sync_asset_delete_v1.dart'; | part 'model/sync_asset_delete_v1.dart'; | ||||||
| part 'model/sync_asset_exif_v1.dart'; | part 'model/sync_asset_exif_v1.dart'; | ||||||
| part 'model/sync_asset_v1.dart'; | part 'model/sync_asset_v1.dart'; | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -532,6 +532,14 @@ class ApiClient { | |||||||
|           return SyncAckDto.fromJson(value); |           return SyncAckDto.fromJson(value); | ||||||
|         case 'SyncAckSetDto': |         case 'SyncAckSetDto': | ||||||
|           return SyncAckSetDto.fromJson(value); |           return SyncAckSetDto.fromJson(value); | ||||||
|  |         case 'SyncAlbumDeleteV1': | ||||||
|  |           return SyncAlbumDeleteV1.fromJson(value); | ||||||
|  |         case 'SyncAlbumUserDeleteV1': | ||||||
|  |           return SyncAlbumUserDeleteV1.fromJson(value); | ||||||
|  |         case 'SyncAlbumUserV1': | ||||||
|  |           return SyncAlbumUserV1.fromJson(value); | ||||||
|  |         case 'SyncAlbumV1': | ||||||
|  |           return SyncAlbumV1.fromJson(value); | ||||||
|         case 'SyncAssetDeleteV1': |         case 'SyncAssetDeleteV1': | ||||||
|           return SyncAssetDeleteV1.fromJson(value); |           return SyncAssetDeleteV1.fromJson(value); | ||||||
|         case 'SyncAssetExifV1': |         case 'SyncAssetExifV1': | ||||||
|   | |||||||
							
								
								
									
										99
									
								
								mobile/openapi/lib/model/sync_album_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								mobile/openapi/lib/model/sync_album_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // ignore_for_file: unused_element, unused_import | ||||||
|  | // ignore_for_file: always_put_required_named_parameters_first | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | // ignore_for_file: lines_longer_than_80_chars | ||||||
|  | 
 | ||||||
|  | part of openapi.api; | ||||||
|  | 
 | ||||||
|  | class SyncAlbumDeleteV1 { | ||||||
|  |   /// Returns a new [SyncAlbumDeleteV1] instance. | ||||||
|  |   SyncAlbumDeleteV1({ | ||||||
|  |     required this.albumId, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String albumId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumDeleteV1 && | ||||||
|  |     other.albumId == albumId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (albumId.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'SyncAlbumDeleteV1[albumId=$albumId]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'albumId'] = this.albumId; | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [SyncAlbumDeleteV1] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static SyncAlbumDeleteV1? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "SyncAlbumDeleteV1"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return SyncAlbumDeleteV1( | ||||||
|  |         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SyncAlbumDeleteV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SyncAlbumDeleteV1>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = SyncAlbumDeleteV1.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, SyncAlbumDeleteV1> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SyncAlbumDeleteV1>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = SyncAlbumDeleteV1.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of SyncAlbumDeleteV1-objects as value to a dart map | ||||||
|  |   static Map<String, List<SyncAlbumDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SyncAlbumDeleteV1>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = SyncAlbumDeleteV1.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'albumId', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										107
									
								
								mobile/openapi/lib/model/sync_album_user_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								mobile/openapi/lib/model/sync_album_user_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // ignore_for_file: unused_element, unused_import | ||||||
|  | // ignore_for_file: always_put_required_named_parameters_first | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | // ignore_for_file: lines_longer_than_80_chars | ||||||
|  | 
 | ||||||
|  | part of openapi.api; | ||||||
|  | 
 | ||||||
|  | class SyncAlbumUserDeleteV1 { | ||||||
|  |   /// Returns a new [SyncAlbumUserDeleteV1] instance. | ||||||
|  |   SyncAlbumUserDeleteV1({ | ||||||
|  |     required this.albumId, | ||||||
|  |     required this.userId, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String albumId; | ||||||
|  | 
 | ||||||
|  |   String userId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserDeleteV1 && | ||||||
|  |     other.albumId == albumId && | ||||||
|  |     other.userId == userId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (albumId.hashCode) + | ||||||
|  |     (userId.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'SyncAlbumUserDeleteV1[albumId=$albumId, userId=$userId]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'albumId'] = this.albumId; | ||||||
|  |       json[r'userId'] = this.userId; | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [SyncAlbumUserDeleteV1] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static SyncAlbumUserDeleteV1? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "SyncAlbumUserDeleteV1"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return SyncAlbumUserDeleteV1( | ||||||
|  |         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||||
|  |         userId: mapValueOfType<String>(json, r'userId')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SyncAlbumUserDeleteV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SyncAlbumUserDeleteV1>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = SyncAlbumUserDeleteV1.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, SyncAlbumUserDeleteV1> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SyncAlbumUserDeleteV1>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = SyncAlbumUserDeleteV1.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of SyncAlbumUserDeleteV1-objects as value to a dart map | ||||||
|  |   static Map<String, List<SyncAlbumUserDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SyncAlbumUserDeleteV1>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = SyncAlbumUserDeleteV1.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'albumId', | ||||||
|  |     'userId', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										189
									
								
								mobile/openapi/lib/model/sync_album_user_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								mobile/openapi/lib/model/sync_album_user_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // ignore_for_file: unused_element, unused_import | ||||||
|  | // ignore_for_file: always_put_required_named_parameters_first | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | // ignore_for_file: lines_longer_than_80_chars | ||||||
|  | 
 | ||||||
|  | part of openapi.api; | ||||||
|  | 
 | ||||||
|  | class SyncAlbumUserV1 { | ||||||
|  |   /// Returns a new [SyncAlbumUserV1] instance. | ||||||
|  |   SyncAlbumUserV1({ | ||||||
|  |     required this.albumId, | ||||||
|  |     required this.role, | ||||||
|  |     required this.userId, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String albumId; | ||||||
|  | 
 | ||||||
|  |   SyncAlbumUserV1RoleEnum role; | ||||||
|  | 
 | ||||||
|  |   String userId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 && | ||||||
|  |     other.albumId == albumId && | ||||||
|  |     other.role == role && | ||||||
|  |     other.userId == userId; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (albumId.hashCode) + | ||||||
|  |     (role.hashCode) + | ||||||
|  |     (userId.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'albumId'] = this.albumId; | ||||||
|  |       json[r'role'] = this.role; | ||||||
|  |       json[r'userId'] = this.userId; | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [SyncAlbumUserV1] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static SyncAlbumUserV1? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "SyncAlbumUserV1"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return SyncAlbumUserV1( | ||||||
|  |         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||||
|  |         role: SyncAlbumUserV1RoleEnum.fromJson(json[r'role'])!, | ||||||
|  |         userId: mapValueOfType<String>(json, r'userId')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SyncAlbumUserV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SyncAlbumUserV1>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = SyncAlbumUserV1.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, SyncAlbumUserV1> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SyncAlbumUserV1>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = SyncAlbumUserV1.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of SyncAlbumUserV1-objects as value to a dart map | ||||||
|  |   static Map<String, List<SyncAlbumUserV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SyncAlbumUserV1>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = SyncAlbumUserV1.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'albumId', | ||||||
|  |     'role', | ||||||
|  |     'userId', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SyncAlbumUserV1RoleEnum { | ||||||
|  |   /// Instantiate a new enum with the provided [value]. | ||||||
|  |   const SyncAlbumUserV1RoleEnum._(this.value); | ||||||
|  | 
 | ||||||
|  |   /// The underlying value of this enum member. | ||||||
|  |   final String value; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => value; | ||||||
|  | 
 | ||||||
|  |   String toJson() => value; | ||||||
|  | 
 | ||||||
|  |   static const editor = SyncAlbumUserV1RoleEnum._(r'editor'); | ||||||
|  |   static const viewer = SyncAlbumUserV1RoleEnum._(r'viewer'); | ||||||
|  | 
 | ||||||
|  |   /// List of all possible values in this [enum][SyncAlbumUserV1RoleEnum]. | ||||||
|  |   static const values = <SyncAlbumUserV1RoleEnum>[ | ||||||
|  |     editor, | ||||||
|  |     viewer, | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|  |   static SyncAlbumUserV1RoleEnum? fromJson(dynamic value) => SyncAlbumUserV1RoleEnumTypeTransformer().decode(value); | ||||||
|  | 
 | ||||||
|  |   static List<SyncAlbumUserV1RoleEnum> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SyncAlbumUserV1RoleEnum>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = SyncAlbumUserV1RoleEnum.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Transformation class that can [encode] an instance of [SyncAlbumUserV1RoleEnum] to String, | ||||||
|  | /// and [decode] dynamic data back to [SyncAlbumUserV1RoleEnum]. | ||||||
|  | class SyncAlbumUserV1RoleEnumTypeTransformer { | ||||||
|  |   factory SyncAlbumUserV1RoleEnumTypeTransformer() => _instance ??= const SyncAlbumUserV1RoleEnumTypeTransformer._(); | ||||||
|  | 
 | ||||||
|  |   const SyncAlbumUserV1RoleEnumTypeTransformer._(); | ||||||
|  | 
 | ||||||
|  |   String encode(SyncAlbumUserV1RoleEnum data) => data.value; | ||||||
|  | 
 | ||||||
|  |   /// Decodes a [dynamic value][data] to a SyncAlbumUserV1RoleEnum. | ||||||
|  |   /// | ||||||
|  |   /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, | ||||||
|  |   /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] | ||||||
|  |   /// cannot be decoded successfully, then an [UnimplementedError] is thrown. | ||||||
|  |   /// | ||||||
|  |   /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, | ||||||
|  |   /// and users are still using an old app with the old code. | ||||||
|  |   SyncAlbumUserV1RoleEnum? decode(dynamic data, {bool allowNull = true}) { | ||||||
|  |     if (data != null) { | ||||||
|  |       switch (data) { | ||||||
|  |         case r'editor': return SyncAlbumUserV1RoleEnum.editor; | ||||||
|  |         case r'viewer': return SyncAlbumUserV1RoleEnum.viewer; | ||||||
|  |         default: | ||||||
|  |           if (!allowNull) { | ||||||
|  |             throw ArgumentError('Unknown enum value to decode: $data'); | ||||||
|  |           } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Singleton [SyncAlbumUserV1RoleEnumTypeTransformer] instance. | ||||||
|  |   static SyncAlbumUserV1RoleEnumTypeTransformer? _instance; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										167
									
								
								mobile/openapi/lib/model/sync_album_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								mobile/openapi/lib/model/sync_album_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // ignore_for_file: unused_element, unused_import | ||||||
|  | // ignore_for_file: always_put_required_named_parameters_first | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | // ignore_for_file: lines_longer_than_80_chars | ||||||
|  | 
 | ||||||
|  | part of openapi.api; | ||||||
|  | 
 | ||||||
|  | class SyncAlbumV1 { | ||||||
|  |   /// Returns a new [SyncAlbumV1] instance. | ||||||
|  |   SyncAlbumV1({ | ||||||
|  |     required this.createdAt, | ||||||
|  |     required this.description, | ||||||
|  |     required this.id, | ||||||
|  |     required this.isActivityEnabled, | ||||||
|  |     required this.name, | ||||||
|  |     required this.order, | ||||||
|  |     required this.ownerId, | ||||||
|  |     required this.thumbnailAssetId, | ||||||
|  |     required this.updatedAt, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   DateTime createdAt; | ||||||
|  | 
 | ||||||
|  |   String description; | ||||||
|  | 
 | ||||||
|  |   String id; | ||||||
|  | 
 | ||||||
|  |   bool isActivityEnabled; | ||||||
|  | 
 | ||||||
|  |   String name; | ||||||
|  | 
 | ||||||
|  |   AssetOrder order; | ||||||
|  | 
 | ||||||
|  |   String ownerId; | ||||||
|  | 
 | ||||||
|  |   String? thumbnailAssetId; | ||||||
|  | 
 | ||||||
|  |   DateTime updatedAt; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumV1 && | ||||||
|  |     other.createdAt == createdAt && | ||||||
|  |     other.description == description && | ||||||
|  |     other.id == id && | ||||||
|  |     other.isActivityEnabled == isActivityEnabled && | ||||||
|  |     other.name == name && | ||||||
|  |     other.order == order && | ||||||
|  |     other.ownerId == ownerId && | ||||||
|  |     other.thumbnailAssetId == thumbnailAssetId && | ||||||
|  |     other.updatedAt == updatedAt; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (createdAt.hashCode) + | ||||||
|  |     (description.hashCode) + | ||||||
|  |     (id.hashCode) + | ||||||
|  |     (isActivityEnabled.hashCode) + | ||||||
|  |     (name.hashCode) + | ||||||
|  |     (order.hashCode) + | ||||||
|  |     (ownerId.hashCode) + | ||||||
|  |     (thumbnailAssetId == null ? 0 : thumbnailAssetId!.hashCode) + | ||||||
|  |     (updatedAt.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'SyncAlbumV1[createdAt=$createdAt, description=$description, id=$id, isActivityEnabled=$isActivityEnabled, name=$name, order=$order, ownerId=$ownerId, thumbnailAssetId=$thumbnailAssetId, updatedAt=$updatedAt]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); | ||||||
|  |       json[r'description'] = this.description; | ||||||
|  |       json[r'id'] = this.id; | ||||||
|  |       json[r'isActivityEnabled'] = this.isActivityEnabled; | ||||||
|  |       json[r'name'] = this.name; | ||||||
|  |       json[r'order'] = this.order; | ||||||
|  |       json[r'ownerId'] = this.ownerId; | ||||||
|  |     if (this.thumbnailAssetId != null) { | ||||||
|  |       json[r'thumbnailAssetId'] = this.thumbnailAssetId; | ||||||
|  |     } else { | ||||||
|  |     //  json[r'thumbnailAssetId'] = null; | ||||||
|  |     } | ||||||
|  |       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [SyncAlbumV1] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static SyncAlbumV1? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "SyncAlbumV1"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return SyncAlbumV1( | ||||||
|  |         createdAt: mapDateTime(json, r'createdAt', r'')!, | ||||||
|  |         description: mapValueOfType<String>(json, r'description')!, | ||||||
|  |         id: mapValueOfType<String>(json, r'id')!, | ||||||
|  |         isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!, | ||||||
|  |         name: mapValueOfType<String>(json, r'name')!, | ||||||
|  |         order: AssetOrder.fromJson(json[r'order'])!, | ||||||
|  |         ownerId: mapValueOfType<String>(json, r'ownerId')!, | ||||||
|  |         thumbnailAssetId: mapValueOfType<String>(json, r'thumbnailAssetId'), | ||||||
|  |         updatedAt: mapDateTime(json, r'updatedAt', r'')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SyncAlbumV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SyncAlbumV1>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = SyncAlbumV1.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, SyncAlbumV1> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SyncAlbumV1>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = SyncAlbumV1.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of SyncAlbumV1-objects as value to a dart map | ||||||
|  |   static Map<String, List<SyncAlbumV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SyncAlbumV1>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = SyncAlbumV1.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'createdAt', | ||||||
|  |     'description', | ||||||
|  |     'id', | ||||||
|  |     'isActivityEnabled', | ||||||
|  |     'name', | ||||||
|  |     'order', | ||||||
|  |     'ownerId', | ||||||
|  |     'thumbnailAssetId', | ||||||
|  |     'updatedAt', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										12
									
								
								mobile/openapi/lib/model/sync_entity_type.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								mobile/openapi/lib/model/sync_entity_type.dart
									
									
									
										generated
									
									
									
								
							| @@ -33,6 +33,10 @@ class SyncEntityType { | |||||||
|   static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); |   static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); | ||||||
|   static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); |   static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); | ||||||
|   static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); |   static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); | ||||||
|  |   static const albumV1 = SyncEntityType._(r'AlbumV1'); | ||||||
|  |   static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1'); | ||||||
|  |   static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); | ||||||
|  |   static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); | ||||||
| 
 | 
 | ||||||
|   /// List of all possible values in this [enum][SyncEntityType]. |   /// List of all possible values in this [enum][SyncEntityType]. | ||||||
|   static const values = <SyncEntityType>[ |   static const values = <SyncEntityType>[ | ||||||
| @@ -46,6 +50,10 @@ class SyncEntityType { | |||||||
|     partnerAssetV1, |     partnerAssetV1, | ||||||
|     partnerAssetDeleteV1, |     partnerAssetDeleteV1, | ||||||
|     partnerAssetExifV1, |     partnerAssetExifV1, | ||||||
|  |     albumV1, | ||||||
|  |     albumDeleteV1, | ||||||
|  |     albumUserV1, | ||||||
|  |     albumUserDeleteV1, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); |   static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); | ||||||
| @@ -94,6 +102,10 @@ class SyncEntityTypeTypeTransformer { | |||||||
|         case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; |         case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; | ||||||
|         case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; |         case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; | ||||||
|         case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; |         case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; | ||||||
|  |         case r'AlbumV1': return SyncEntityType.albumV1; | ||||||
|  |         case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1; | ||||||
|  |         case r'AlbumUserV1': return SyncEntityType.albumUserV1; | ||||||
|  |         case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; | ||||||
|         default: |         default: | ||||||
|           if (!allowNull) { |           if (!allowNull) { | ||||||
|             throw ArgumentError('Unknown enum value to decode: $data'); |             throw ArgumentError('Unknown enum value to decode: $data'); | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								mobile/openapi/lib/model/sync_request_type.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/lib/model/sync_request_type.dart
									
									
									
										generated
									
									
									
								
							| @@ -29,6 +29,8 @@ class SyncRequestType { | |||||||
|   static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); |   static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); | ||||||
|   static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); |   static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); | ||||||
|   static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); |   static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); | ||||||
|  |   static const albumsV1 = SyncRequestType._(r'AlbumsV1'); | ||||||
|  |   static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1'); | ||||||
| 
 | 
 | ||||||
|   /// List of all possible values in this [enum][SyncRequestType]. |   /// List of all possible values in this [enum][SyncRequestType]. | ||||||
|   static const values = <SyncRequestType>[ |   static const values = <SyncRequestType>[ | ||||||
| @@ -38,6 +40,8 @@ class SyncRequestType { | |||||||
|     assetExifsV1, |     assetExifsV1, | ||||||
|     partnerAssetsV1, |     partnerAssetsV1, | ||||||
|     partnerAssetExifsV1, |     partnerAssetExifsV1, | ||||||
|  |     albumsV1, | ||||||
|  |     albumUsersV1, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); |   static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); | ||||||
| @@ -82,6 +86,8 @@ class SyncRequestTypeTypeTransformer { | |||||||
|         case r'AssetExifsV1': return SyncRequestType.assetExifsV1; |         case r'AssetExifsV1': return SyncRequestType.assetExifsV1; | ||||||
|         case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; |         case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; | ||||||
|         case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; |         case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; | ||||||
|  |         case r'AlbumsV1': return SyncRequestType.albumsV1; | ||||||
|  |         case r'AlbumUsersV1': return SyncRequestType.albumUsersV1; | ||||||
|         default: |         default: | ||||||
|           if (!allowNull) { |           if (!allowNull) { | ||||||
|             throw ArgumentError('Unknown enum value to decode: $data'); |             throw ArgumentError('Unknown enum value to decode: $data'); | ||||||
|   | |||||||
| @@ -12710,6 +12710,105 @@ | |||||||
|         ], |         ], | ||||||
|         "type": "object" |         "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": { |       "SyncAssetDeleteV1": { | ||||||
|         "properties": { |         "properties": { | ||||||
|           "assetId": { |           "assetId": { | ||||||
| @@ -12937,7 +13036,11 @@ | |||||||
|           "AssetExifV1", |           "AssetExifV1", | ||||||
|           "PartnerAssetV1", |           "PartnerAssetV1", | ||||||
|           "PartnerAssetDeleteV1", |           "PartnerAssetDeleteV1", | ||||||
|           "PartnerAssetExifV1" |           "PartnerAssetExifV1", | ||||||
|  |           "AlbumV1", | ||||||
|  |           "AlbumDeleteV1", | ||||||
|  |           "AlbumUserV1", | ||||||
|  |           "AlbumUserDeleteV1" | ||||||
|         ], |         ], | ||||||
|         "type": "string" |         "type": "string" | ||||||
|       }, |       }, | ||||||
| @@ -12982,7 +13085,9 @@ | |||||||
|           "AssetsV1", |           "AssetsV1", | ||||||
|           "AssetExifsV1", |           "AssetExifsV1", | ||||||
|           "PartnerAssetsV1", |           "PartnerAssetsV1", | ||||||
|           "PartnerAssetExifsV1" |           "PartnerAssetExifsV1", | ||||||
|  |           "AlbumsV1", | ||||||
|  |           "AlbumUsersV1" | ||||||
|         ], |         ], | ||||||
|         "type": "string" |         "type": "string" | ||||||
|       }, |       }, | ||||||
|   | |||||||
| @@ -3860,7 +3860,11 @@ export enum SyncEntityType { | |||||||
|     AssetExifV1 = "AssetExifV1", |     AssetExifV1 = "AssetExifV1", | ||||||
|     PartnerAssetV1 = "PartnerAssetV1", |     PartnerAssetV1 = "PartnerAssetV1", | ||||||
|     PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", |     PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", | ||||||
|     PartnerAssetExifV1 = "PartnerAssetExifV1" |     PartnerAssetExifV1 = "PartnerAssetExifV1", | ||||||
|  |     AlbumV1 = "AlbumV1", | ||||||
|  |     AlbumDeleteV1 = "AlbumDeleteV1", | ||||||
|  |     AlbumUserV1 = "AlbumUserV1", | ||||||
|  |     AlbumUserDeleteV1 = "AlbumUserDeleteV1" | ||||||
| } | } | ||||||
| export enum SyncRequestType { | export enum SyncRequestType { | ||||||
|     UsersV1 = "UsersV1", |     UsersV1 = "UsersV1", | ||||||
| @@ -3868,7 +3872,9 @@ export enum SyncRequestType { | |||||||
|     AssetsV1 = "AssetsV1", |     AssetsV1 = "AssetsV1", | ||||||
|     AssetExifsV1 = "AssetExifsV1", |     AssetExifsV1 = "AssetExifsV1", | ||||||
|     PartnerAssetsV1 = "PartnerAssetsV1", |     PartnerAssetsV1 = "PartnerAssetsV1", | ||||||
|     PartnerAssetExifsV1 = "PartnerAssetExifsV1" |     PartnerAssetExifsV1 = "PartnerAssetExifsV1", | ||||||
|  |     AlbumsV1 = "AlbumsV1", | ||||||
|  |     AlbumUsersV1 = "AlbumUsersV1" | ||||||
| } | } | ||||||
| export enum TranscodeHWAccel { | export enum TranscodeHWAccel { | ||||||
|     Nvenc = "nvenc", |     Nvenc = "nvenc", | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ | |||||||
|     "test:medium": "vitest --config test/vitest.config.medium.mjs", |     "test:medium": "vitest --config test/vitest.config.medium.mjs", | ||||||
|     "typeorm": "typeorm", |     "typeorm": "typeorm", | ||||||
|     "lifecycle": "node ./dist/utils/lifecycle.js", |     "lifecycle": "node ./dist/utils/lifecycle.js", | ||||||
|  |     "migrations:debug": "node ./dist/bin/migrations.js debug", | ||||||
|     "migrations:generate": "node ./dist/bin/migrations.js generate", |     "migrations:generate": "node ./dist/bin/migrations.js generate", | ||||||
|     "migrations:create": "node ./dist/bin/migrations.js create", |     "migrations:create": "node ./dist/bin/migrations.js create", | ||||||
|     "migrations:run": "node ./dist/bin/migrations.js run", |     "migrations:run": "node ./dist/bin/migrations.js run", | ||||||
|   | |||||||
| @@ -125,6 +125,7 @@ const compare = async () => { | |||||||
|   const down = schemaDiff(target, source, { |   const down = schemaDiff(target, source, { | ||||||
|     tables: { ignoreExtra: false }, |     tables: { ignoreExtra: false }, | ||||||
|     functions: { ignoreExtra: false }, |     functions: { ignoreExtra: false }, | ||||||
|  |     extension: { ignoreMissing: true }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   return { up, down }; |   return { up, down }; | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -74,6 +74,20 @@ export interface Albums { | |||||||
|   updateId: Generated<string>; |   updateId: Generated<string>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface AlbumsAudit { | ||||||
|  |   deletedAt: Generated<Timestamp>; | ||||||
|  |   id: Generated<string>; | ||||||
|  |   albumId: string; | ||||||
|  |   userId: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface AlbumUsersAudit { | ||||||
|  |   deletedAt: Generated<Timestamp>; | ||||||
|  |   id: Generated<string>; | ||||||
|  |   albumId: string; | ||||||
|  |   userId: string; | ||||||
|  | } | ||||||
|  |  | ||||||
| export interface AlbumsAssetsAssets { | export interface AlbumsAssetsAssets { | ||||||
|   albumsId: string; |   albumsId: string; | ||||||
|   assetsId: string; |   assetsId: string; | ||||||
| @@ -84,6 +98,8 @@ export interface AlbumsSharedUsersUsers { | |||||||
|   albumsId: string; |   albumsId: string; | ||||||
|   role: Generated<AlbumUserRole>; |   role: Generated<AlbumUserRole>; | ||||||
|   usersId: string; |   usersId: string; | ||||||
|  |   updatedAt: Generated<Timestamp>; | ||||||
|  |   updateId: Generated<string>; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface ApiKeys { | export interface ApiKeys { | ||||||
| @@ -466,8 +482,10 @@ export interface VersionHistory { | |||||||
| export interface DB { | export interface DB { | ||||||
|   activity: Activity; |   activity: Activity; | ||||||
|   albums: Albums; |   albums: Albums; | ||||||
|  |   albums_audit: AlbumsAudit; | ||||||
|   albums_assets_assets: AlbumsAssetsAssets; |   albums_assets_assets: AlbumsAssetsAssets; | ||||||
|   albums_shared_users_users: AlbumsSharedUsersUsers; |   albums_shared_users_users: AlbumsSharedUsersUsers; | ||||||
|  |   album_users_audit: AlbumUsersAudit; | ||||||
|   api_keys: ApiKeys; |   api_keys: ApiKeys; | ||||||
|   asset_faces: AssetFaces; |   asset_faces: AssetFaces; | ||||||
|   asset_files: AssetFiles; |   asset_files: AssetFiles; | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { ApiProperty } from '@nestjs/swagger'; | import { ApiProperty } from '@nestjs/swagger'; | ||||||
| import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; | import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; | ||||||
| import { AssetResponseDto } from 'src/dtos/asset-response.dto'; | 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'; | import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; | ||||||
|  |  | ||||||
| export class AssetFullSyncDto { | export class AssetFullSyncDto { | ||||||
| @@ -112,6 +112,34 @@ export class SyncAssetExifV1 { | |||||||
|   fps!: number | null; |   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 = { | export type SyncItem = { | ||||||
|   [SyncEntityType.UserV1]: SyncUserV1; |   [SyncEntityType.UserV1]: SyncUserV1; | ||||||
|   [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; |   [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; | ||||||
| @@ -123,10 +151,13 @@ export type SyncItem = { | |||||||
|   [SyncEntityType.PartnerAssetV1]: SyncAssetV1; |   [SyncEntityType.PartnerAssetV1]: SyncAssetV1; | ||||||
|   [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; |   [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; | ||||||
|   [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; |   [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; | ||||||
|  |   [SyncEntityType.AlbumV1]: SyncAlbumV1; | ||||||
|  |   [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1; | ||||||
|  |   [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; | ||||||
|  |   [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const responseDtos = [ | const responseDtos = [ | ||||||
|   // |  | ||||||
|   SyncUserV1, |   SyncUserV1, | ||||||
|   SyncUserDeleteV1, |   SyncUserDeleteV1, | ||||||
|   SyncPartnerV1, |   SyncPartnerV1, | ||||||
| @@ -134,6 +165,10 @@ const responseDtos = [ | |||||||
|   SyncAssetV1, |   SyncAssetV1, | ||||||
|   SyncAssetDeleteV1, |   SyncAssetDeleteV1, | ||||||
|   SyncAssetExifV1, |   SyncAssetExifV1, | ||||||
|  |   SyncAlbumV1, | ||||||
|  |   SyncAlbumDeleteV1, | ||||||
|  |   SyncAlbumUserV1, | ||||||
|  |   SyncAlbumUserDeleteV1, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| export const extraSyncModels = responseDtos; | export const extraSyncModels = responseDtos; | ||||||
|   | |||||||
| @@ -578,6 +578,8 @@ export enum SyncRequestType { | |||||||
|   AssetExifsV1 = 'AssetExifsV1', |   AssetExifsV1 = 'AssetExifsV1', | ||||||
|   PartnerAssetsV1 = 'PartnerAssetsV1', |   PartnerAssetsV1 = 'PartnerAssetsV1', | ||||||
|   PartnerAssetExifsV1 = 'PartnerAssetExifsV1', |   PartnerAssetExifsV1 = 'PartnerAssetExifsV1', | ||||||
|  |   AlbumsV1 = 'AlbumsV1', | ||||||
|  |   AlbumUsersV1 = 'AlbumUsersV1', | ||||||
| } | } | ||||||
|  |  | ||||||
| export enum SyncEntityType { | export enum SyncEntityType { | ||||||
| @@ -594,6 +596,11 @@ export enum SyncEntityType { | |||||||
|   PartnerAssetV1 = 'PartnerAssetV1', |   PartnerAssetV1 = 'PartnerAssetV1', | ||||||
|   PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', |   PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', | ||||||
|   PartnerAssetExifV1 = 'PartnerAssetExifV1', |   PartnerAssetExifV1 = 'PartnerAssetExifV1', | ||||||
|  |  | ||||||
|  |   AlbumV1 = 'AlbumV1', | ||||||
|  |   AlbumDeleteV1 = 'AlbumDeleteV1', | ||||||
|  |   AlbumUserV1 = 'AlbumUserV1', | ||||||
|  |   AlbumUserDeleteV1 = 'AlbumUserDeleteV1', | ||||||
| } | } | ||||||
|  |  | ||||||
| export enum NotificationLevel { | export enum NotificationLevel { | ||||||
|   | |||||||
| @@ -6,7 +6,9 @@ insert into | |||||||
| values | values | ||||||
|   ($1, $2) |   ($1, $2) | ||||||
| returning | returning | ||||||
|   * |   "usersId", | ||||||
|  |   "albumsId", | ||||||
|  |   "role" | ||||||
|  |  | ||||||
| -- AlbumUserRepository.update | -- AlbumUserRepository.update | ||||||
| update "albums_shared_users_users" | update "albums_shared_users_users" | ||||||
|   | |||||||
| @@ -246,3 +246,98 @@ where | |||||||
|   and "updatedAt" < now() - interval '1 millisecond' |   and "updatedAt" < now() - interval '1 millisecond' | ||||||
| order by | order by | ||||||
|   "updateId" asc |   "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 | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { Injectable } from '@nestjs/common'; | import { Injectable } from '@nestjs/common'; | ||||||
| import { Insertable, Kysely, Selectable, Updateable } from 'kysely'; | import { Insertable, Kysely, Updateable } from 'kysely'; | ||||||
| import { InjectKysely } from 'nestjs-kysely'; | import { InjectKysely } from 'nestjs-kysely'; | ||||||
| import { AlbumsSharedUsersUsers, DB } from 'src/db'; | import { AlbumsSharedUsersUsers, DB } from 'src/db'; | ||||||
| import { DummyValue, GenerateSql } from 'src/decorators'; | import { DummyValue, GenerateSql } from 'src/decorators'; | ||||||
| @@ -15,8 +15,12 @@ export class AlbumUserRepository { | |||||||
|   constructor(@InjectKysely() private db: Kysely<DB>) {} |   constructor(@InjectKysely() private db: Kysely<DB>) {} | ||||||
|  |  | ||||||
|   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) |   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) | ||||||
|   create(albumUser: Insertable<AlbumsSharedUsersUsers>): Promise<Selectable<AlbumsSharedUsersUsers>> { |   create(albumUser: Insertable<AlbumsSharedUsersUsers>) { | ||||||
|     return this.db.insertInto('albums_shared_users_users').values(albumUser).returningAll().executeTakeFirstOrThrow(); |     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 }] }) |   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] }) | ||||||
|   | |||||||
| @@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators'; | |||||||
| import { SyncEntityType } from 'src/enum'; | import { SyncEntityType } from 'src/enum'; | ||||||
| import { SyncAck } from 'src/types'; | import { SyncAck } from 'src/types'; | ||||||
|  |  | ||||||
| type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit'; | type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit'; | ||||||
| type upsertTables = 'users' | 'partners' | 'assets' | 'exif'; | type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users'; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class SyncRepository { | export class SyncRepository { | ||||||
| @@ -110,7 +110,6 @@ export class SyncRepository { | |||||||
|       .selectFrom('assets_audit') |       .selectFrom('assets_audit') | ||||||
|       .select(['id', 'assetId']) |       .select(['id', 'assetId']) | ||||||
|       .where('ownerId', '=', userId) |       .where('ownerId', '=', userId) | ||||||
|       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) |  | ||||||
|       .$call((qb) => this.auditTableFilters(qb, ack)) |       .$call((qb) => this.auditTableFilters(qb, ack)) | ||||||
|       .stream(); |       .stream(); | ||||||
|   } |   } | ||||||
| @@ -154,19 +153,115 @@ export class SyncRepository { | |||||||
|       .stream(); |       .stream(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private auditTableFilters<T extends keyof Pick<DB, auditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) { |   @GenerateSql({ params: [DummyValue.UUID], stream: true }) | ||||||
|     const builder = qb as SelectQueryBuilder<DB, auditTables, D>; |   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<Date>("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<Date>("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<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) { | ||||||
|  |     const builder = qb as SelectQueryBuilder<DB, AuditTables, D>; | ||||||
|     return builder |     return builder | ||||||
|       .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) |       .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||||
|       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) |       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) | ||||||
|       .orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>; |       .orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private upsertTableFilters<T extends keyof Pick<DB, upsertTables>, D>( |   private upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>( | ||||||
|     qb: SelectQueryBuilder<DB, T, D>, |     qb: SelectQueryBuilder<DB, T, D>, | ||||||
|     ack?: SyncAck, |     ack?: SyncAck, | ||||||
|   ) { |   ) { | ||||||
|     const builder = qb as SelectQueryBuilder<DB, upsertTables, D>; |     const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>; | ||||||
|     return builder |     return builder | ||||||
|       .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) |       .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||||
|       .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) |       .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) | ||||||
|   | |||||||
| @@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({ | |||||||
|   synchronize: false, |   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({ | export const updated_at = registerFunction({ | ||||||
|   name: 'updated_at', |   name: 'updated_at', | ||||||
|   returnType: 'TRIGGER', |   returnType: 'TRIGGER', | ||||||
| @@ -114,3 +127,38 @@ export const assets_delete_audit = registerFunction({ | |||||||
|     END`, |     END`, | ||||||
|   synchronize: false, |   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, | ||||||
|  | }); | ||||||
|   | |||||||
| @@ -1,5 +1,8 @@ | |||||||
| import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; | import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; | ||||||
| import { | import { | ||||||
|  |   album_user_after_insert, | ||||||
|  |   album_users_delete_audit, | ||||||
|  |   albums_delete_audit, | ||||||
|   assets_delete_audit, |   assets_delete_audit, | ||||||
|   f_concat_ws, |   f_concat_ws, | ||||||
|   f_unaccent, |   f_unaccent, | ||||||
| @@ -11,6 +14,8 @@ import { | |||||||
| } from 'src/schema/functions'; | } from 'src/schema/functions'; | ||||||
| import { ActivityTable } from 'src/schema/tables/activity.table'; | import { ActivityTable } from 'src/schema/tables/activity.table'; | ||||||
| import { AlbumAssetTable } from 'src/schema/tables/album-asset.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 { AlbumUserTable } from 'src/schema/tables/album-user.table'; | ||||||
| import { AlbumTable } from 'src/schema/tables/album.table'; | import { AlbumTable } from 'src/schema/tables/album.table'; | ||||||
| import { APIKeyTable } from 'src/schema/tables/api-key.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 { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; | ||||||
| import { UserTable } from 'src/schema/tables/user.table'; | import { UserTable } from 'src/schema/tables/user.table'; | ||||||
| import { VersionHistoryTable } from 'src/schema/tables/version-history.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']) | @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) | ||||||
| @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) |  | ||||||
| @Database({ name: 'immich' }) | @Database({ name: 'immich' }) | ||||||
| export class ImmichDatabase { | export class ImmichDatabase { | ||||||
|   tables = [ |   tables = [ | ||||||
|     ActivityTable, |     ActivityTable, | ||||||
|     AlbumAssetTable, |     AlbumAssetTable, | ||||||
|  |     AlbumAuditTable, | ||||||
|  |     AlbumUserAuditTable, | ||||||
|     AlbumUserTable, |     AlbumUserTable, | ||||||
|     AlbumTable, |     AlbumTable, | ||||||
|     APIKeyTable, |     APIKeyTable, | ||||||
| @@ -99,6 +105,9 @@ export class ImmichDatabase { | |||||||
|     users_delete_audit, |     users_delete_audit, | ||||||
|     partners_delete_audit, |     partners_delete_audit, | ||||||
|     assets_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]; |   enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; | ||||||
|   | |||||||
| @@ -0,0 +1,96 @@ | |||||||
|  | import { Kysely, sql } from 'kysely'; | ||||||
|  |  | ||||||
|  | export async function up(db: Kysely<any>): Promise<void> { | ||||||
|  |   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<any>): Promise<void> { | ||||||
|  |   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); | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								server/src/schema/tables/album-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/src/schema/tables/album-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								server/src/schema/tables/album-user-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/src/schema/tables/album-user-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
|  | } | ||||||
| @@ -1,12 +1,36 @@ | |||||||
|  | import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; | ||||||
| import { AlbumUserRole } from 'src/enum'; | 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 { AlbumTable } from 'src/schema/tables/album.table'; | ||||||
| import { UserTable } from 'src/schema/tables/user.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' }) | @Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' }) | ||||||
| // Pre-existing indices from original album <--> user ManyToMany mapping | // Pre-existing indices from original album <--> user ManyToMany mapping | ||||||
| @Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] }) | @Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] }) | ||||||
| @Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] }) | @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 { | export class AlbumUserTable { | ||||||
|   @ForeignKeyColumn(() => AlbumTable, { |   @ForeignKeyColumn(() => AlbumTable, { | ||||||
|     onDelete: 'CASCADE', |     onDelete: 'CASCADE', | ||||||
| @@ -26,4 +50,10 @@ export class AlbumUserTable { | |||||||
|  |  | ||||||
|   @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) |   @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) | ||||||
|   role!: AlbumUserRole; |   role!: AlbumUserRole; | ||||||
|  |  | ||||||
|  |   @UpdateIdColumn({ indexName: 'IDX_album_users_update_id' }) | ||||||
|  |   updateId?: string; | ||||||
|  |  | ||||||
|  |   @UpdateDateColumn() | ||||||
|  |   updatedAt!: Date; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,8 +1,10 @@ | |||||||
| import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; | import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; | ||||||
| import { AssetOrder } from 'src/enum'; | import { AssetOrder } from 'src/enum'; | ||||||
|  | import { albums_delete_audit } from 'src/schema/functions'; | ||||||
| import { AssetTable } from 'src/schema/tables/asset.table'; | import { AssetTable } from 'src/schema/tables/asset.table'; | ||||||
| import { UserTable } from 'src/schema/tables/user.table'; | import { UserTable } from 'src/schema/tables/user.table'; | ||||||
| import { | import { | ||||||
|  |   AfterDeleteTrigger, | ||||||
|   Column, |   Column, | ||||||
|   CreateDateColumn, |   CreateDateColumn, | ||||||
|   DeleteDateColumn, |   DeleteDateColumn, | ||||||
| @@ -14,6 +16,13 @@ import { | |||||||
|  |  | ||||||
| @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) | @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) | ||||||
| @UpdatedAtTrigger('albums_updated_at') | @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 { | export class AlbumTable { | ||||||
|   @PrimaryGeneratedColumn() |   @PrimaryGeneratedColumn() | ||||||
|   id!: string; |   id!: string; | ||||||
|   | |||||||
| @@ -24,13 +24,14 @@ import { fromAck, serialize } from 'src/utils/sync'; | |||||||
|  |  | ||||||
| const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; | const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; | ||||||
| export const SYNC_TYPES_ORDER = [ | export const SYNC_TYPES_ORDER = [ | ||||||
|   // |  | ||||||
|   SyncRequestType.UsersV1, |   SyncRequestType.UsersV1, | ||||||
|   SyncRequestType.PartnersV1, |   SyncRequestType.PartnersV1, | ||||||
|   SyncRequestType.AssetsV1, |   SyncRequestType.AssetsV1, | ||||||
|   SyncRequestType.AssetExifsV1, |   SyncRequestType.AssetExifsV1, | ||||||
|   SyncRequestType.PartnerAssetsV1, |   SyncRequestType.PartnerAssetsV1, | ||||||
|   SyncRequestType.PartnerAssetExifsV1, |   SyncRequestType.PartnerAssetExifsV1, | ||||||
|  |   SyncRequestType.AlbumsV1, | ||||||
|  |   SyncRequestType.AlbumUsersV1, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const throwSessionRequired = () => { | const throwSessionRequired = () => { | ||||||
| @@ -206,6 +207,43 @@ export class SyncService extends BaseService { | |||||||
|           break; |           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: { |         default: { | ||||||
|           this.logger.warn(`Unsupported sync type: ${type}`); |           this.logger.warn(`Unsupported sync type: ${type}`); | ||||||
|           break; |           break; | ||||||
|   | |||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; | ||||||
|  |  | ||||||
|  | export const AfterInsertTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) => | ||||||
|  |   TriggerFunction({ | ||||||
|  |     timing: 'after', | ||||||
|  |     actions: ['insert'], | ||||||
|  |     ...options, | ||||||
|  |   }); | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| export { schemaDiff } from 'src/sql-tools/diff'; | export { schemaDiff } from 'src/sql-tools/diff'; | ||||||
| export { schemaFromCode } from 'src/sql-tools/from-code'; | 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-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/before-update.decorator'; | ||||||
| export * from 'src/sql-tools/from-code/decorators/check.decorator'; | export * from 'src/sql-tools/from-code/decorators/check.decorator'; | ||||||
| export * from 'src/sql-tools/from-code/decorators/column.decorator'; | export * from 'src/sql-tools/from-code/decorators/column.decorator'; | ||||||
|   | |||||||
| @@ -4,9 +4,11 @@ import { DateTime } from 'luxon'; | |||||||
| import { createHash, randomBytes } from 'node:crypto'; | import { createHash, randomBytes } from 'node:crypto'; | ||||||
| import { Writable } from 'node:stream'; | import { Writable } from 'node:stream'; | ||||||
| import { AssetFace } from 'src/database'; | import { AssetFace } from 'src/database'; | ||||||
| import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; | import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; | ||||||
| import { AssetType, AssetVisibility, SourceType } from 'src/enum'; | import { AuthDto } from 'src/dtos/auth.dto'; | ||||||
|  | import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum'; | ||||||
| import { ActivityRepository } from 'src/repositories/activity.repository'; | import { ActivityRepository } from 'src/repositories/activity.repository'; | ||||||
|  | import { AlbumUserRepository } from 'src/repositories/album-user.repository'; | ||||||
| import { AlbumRepository } from 'src/repositories/album.repository'; | import { AlbumRepository } from 'src/repositories/album.repository'; | ||||||
| import { AssetJobRepository } from 'src/repositories/asset-job.repository'; | import { AssetJobRepository } from 'src/repositories/asset-job.repository'; | ||||||
| import { AssetRepository } from 'src/repositories/asset.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 { VersionHistoryRepository } from 'src/repositories/version-history.repository'; | ||||||
| import { UserTable } from 'src/schema/tables/user.table'; | import { UserTable } from 'src/schema/tables/user.table'; | ||||||
| import { BaseService } from 'src/services/base.service'; | import { BaseService } from 'src/services/base.service'; | ||||||
|  | import { SyncService } from 'src/services/sync.service'; | ||||||
| import { RepositoryInterface } from 'src/types'; | 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 { automock, ServiceOverrides } from 'test/utils'; | ||||||
| import { Mocked } from 'vitest'; | import { Mocked } from 'vitest'; | ||||||
|  |  | ||||||
| @@ -39,6 +42,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas | |||||||
| type RepositoriesTypes = { | type RepositoriesTypes = { | ||||||
|   activity: ActivityRepository; |   activity: ActivityRepository; | ||||||
|   album: AlbumRepository; |   album: AlbumRepository; | ||||||
|  |   albumUser: AlbumUserRepository; | ||||||
|   asset: AssetRepository; |   asset: AssetRepository; | ||||||
|   assetJob: AssetJobRepository; |   assetJob: AssetJobRepository; | ||||||
|   config: ConfigRepository; |   config: ConfigRepository; | ||||||
| @@ -76,6 +80,61 @@ export type Context<R extends RepositoryOptions, S extends BaseService> = { | |||||||
|   getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T]; |   getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T]; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export type SyncTestOptions = { | ||||||
|  |   db: Kysely<DB>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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<DB>) => { | ||||||
|  |       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 = <R extends RepositoryOptions, S extends BaseService>( | export const newMediumService = <R extends RepositoryOptions, S extends BaseService>( | ||||||
|   Service: ClassConstructor<S>, |   Service: ClassConstructor<S>, | ||||||
|   options: { |   options: { | ||||||
| @@ -125,6 +184,14 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys | |||||||
|       return new ActivityRepository(db); |       return new ActivityRepository(db); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     case 'album': { | ||||||
|  |       return new AlbumRepository(db); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     case 'albumUser': { | ||||||
|  |       return new AlbumUserRepository(db); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     case 'asset': { |     case 'asset': { | ||||||
|       return new AssetRepository(db); |       return new AssetRepository(db); | ||||||
|     } |     } | ||||||
| @@ -380,6 +447,19 @@ const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const albumInsert = (album: Partial<Insertable<Albums>> & { ownerId: string }) => { | ||||||
|  |   const id = album.id || newUuid(); | ||||||
|  |   const defaults: Omit<Insertable<Albums>, 'ownerId'> = { | ||||||
|  |     albumName: 'Album', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     ...defaults, | ||||||
|  |     ...album, | ||||||
|  |     id, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
| const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => { | const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => { | ||||||
|   const defaults = { |   const defaults = { | ||||||
|     faceId: face.faceId, |     faceId: face.faceId, | ||||||
| @@ -502,6 +582,7 @@ export const mediumFactory = { | |||||||
|   assetInsert, |   assetInsert, | ||||||
|   assetFaceInsert, |   assetFaceInsert, | ||||||
|   assetJobStatusInsert, |   assetJobStatusInsert, | ||||||
|  |   albumInsert, | ||||||
|   faceInsert, |   faceInsert, | ||||||
|   personInsert, |   personInsert, | ||||||
|   sessionInsert, |   sessionInsert, | ||||||
|   | |||||||
| @@ -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); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
							
								
								
									
										269
									
								
								server/test/medium/specs/sync/sync-album-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								server/test/medium/specs/sync/sync-album-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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, | ||||||
|  |         }, | ||||||
|  |       ]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										220
									
								
								server/test/medium/specs/sync/sync-album.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								server/test/medium/specs/sync/sync-album.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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, | ||||||
|  |         }, | ||||||
|  |       ]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										100
									
								
								server/test/medium/specs/sync/sync-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								server/test/medium/specs/sync/sync-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										130
									
								
								server/test/medium/specs/sync/sync-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								server/test/medium/specs/sync/sync-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										129
									
								
								server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										208
									
								
								server/test/medium/specs/sync/sync-partner-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								server/test/medium/specs/sync/sync-partner-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										221
									
								
								server/test/medium/specs/sync/sync-partner.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								server/test/medium/specs/sync/sync-partner.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										12
									
								
								server/test/medium/specs/sync/sync-types.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/test/medium/specs/sync/sync-types.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										179
									
								
								server/test/medium/specs/sync/sync-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								server/test/medium/specs/sync/sync-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = async (db?: Kysely<DB>) => { | ||||||
|  |   const database = db || defaultDatabase; | ||||||
|  |   const result = newSyncTest({ db: database }); | ||||||
|  |   const { auth, create } = newSyncAuthUser(); | ||||||
|  |   await create(database); | ||||||
|  |   return { ...result, auth }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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', | ||||||
|  |         }, | ||||||
|  |       ]), | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
		Reference in New Issue
	
	Block a user