feat: memories sync (#19644)

* feat: memories sync

* Update mobile/lib/infrastructure/repositories/sync_stream.repository.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update mobile/lib/infrastructure/repositories/sync_stream.repository.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* show sync information

* tests and pr feedback

* pr feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Alex 2025-07-02 14:18:37 -05:00 committed by GitHub
parent 7855974a29
commit 445f9174ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 601 additions and 27 deletions

Binary file not shown.

View file

@ -0,0 +1,202 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum MemoryTypeEnum {
// do not change this order!
onThisDay,
}
class MemoryData {
final int year;
const MemoryData({
required this.year,
});
MemoryData copyWith({
int? year,
}) {
return MemoryData(
year: year ?? this.year,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'year': year,
};
}
factory MemoryData.fromMap(Map<String, dynamic> map) {
return MemoryData(
year: map['year'] as int,
);
}
String toJson() => json.encode(toMap());
factory MemoryData.fromJson(String source) =>
MemoryData.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'MemoryData(year: $year)';
@override
bool operator ==(covariant MemoryData other) {
if (identical(this, other)) return true;
return other.year == year;
}
@override
int get hashCode => year.hashCode;
}
// Model for a memory stored in the server
class Memory {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? deletedAt;
final String ownerId;
// enum
final MemoryTypeEnum type;
final MemoryData data;
final bool isSaved;
final DateTime memoryAt;
final DateTime? seenAt;
final DateTime? showAt;
final DateTime? hideAt;
const Memory({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.ownerId,
required this.type,
required this.data,
required this.isSaved,
required this.memoryAt,
this.seenAt,
this.showAt,
this.hideAt,
});
Memory copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? deletedAt,
String? ownerId,
MemoryTypeEnum? type,
MemoryData? data,
bool? isSaved,
DateTime? memoryAt,
DateTime? seenAt,
DateTime? showAt,
DateTime? hideAt,
}) {
return Memory(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
ownerId: ownerId ?? this.ownerId,
type: type ?? this.type,
data: data ?? this.data,
isSaved: isSaved ?? this.isSaved,
memoryAt: memoryAt ?? this.memoryAt,
seenAt: seenAt ?? this.seenAt,
showAt: showAt ?? this.showAt,
hideAt: hideAt ?? this.hideAt,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'deletedAt': deletedAt?.millisecondsSinceEpoch,
'ownerId': ownerId,
'type': type.index,
'data': data.toMap(),
'isSaved': isSaved,
'memoryAt': memoryAt.millisecondsSinceEpoch,
'seenAt': seenAt?.millisecondsSinceEpoch,
'showAt': showAt?.millisecondsSinceEpoch,
'hideAt': hideAt?.millisecondsSinceEpoch,
};
}
factory Memory.fromMap(Map<String, dynamic> map) {
return Memory(
id: map['id'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
deletedAt: map['deletedAt'] != null
? DateTime.fromMillisecondsSinceEpoch(map['deletedAt'] as int)
: null,
ownerId: map['ownerId'] as String,
type: MemoryTypeEnum.values[map['type'] as int],
data: MemoryData.fromMap(map['data'] as Map<String, dynamic>),
isSaved: map['isSaved'] as bool,
memoryAt: DateTime.fromMillisecondsSinceEpoch(map['memoryAt'] as int),
seenAt: map['seenAt'] != null
? DateTime.fromMillisecondsSinceEpoch(map['seenAt'] as int)
: null,
showAt: map['showAt'] != null
? DateTime.fromMillisecondsSinceEpoch(map['showAt'] as int)
: null,
hideAt: map['hideAt'] != null
? DateTime.fromMillisecondsSinceEpoch(map['hideAt'] as int)
: null,
);
}
String toJson() => json.encode(toMap());
factory Memory.fromJson(String source) =>
Memory.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt)';
}
@override
bool operator ==(covariant Memory other) {
if (identical(this, other)) return true;
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.deletedAt == deletedAt &&
other.ownerId == ownerId &&
other.type == type &&
other.data == data &&
other.isSaved == isSaved &&
other.memoryAt == memoryAt &&
other.seenAt == seenAt &&
other.showAt == showAt &&
other.hideAt == hideAt;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
deletedAt.hashCode ^
ownerId.hashCode ^
type.hashCode ^
data.hashCode ^
isSaved.hashCode ^
memoryAt.hashCode ^
seenAt.hashCode ^
showAt.hashCode ^
hideAt.hashCode;
}
}

View file

@ -146,6 +146,14 @@ class SyncStreamService {
// to acknowledge that the client has processed all the backfill events
case SyncEntityType.syncAckV1:
return;
case SyncEntityType.memoryV1:
return _syncStreamRepository.updateMemoriesV1(data.cast());
case SyncEntityType.memoryDeleteV1:
return _syncStreamRepository.deleteMemoriesV1(data.cast());
case SyncEntityType.memoryToAssetV1:
return _syncStreamRepository.updateMemoryAssetsV1(data.cast());
case SyncEntityType.memoryToAssetDeleteV1:
return _syncStreamRepository.deleteMemoryAssetsV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}

View file

@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class MemoryEntity extends Table with DriftDefaultsMixin {
const MemoryEntity();
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get deletedAt => dateTime().nullable()();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get type => intEnum<MemoryTypeEnum>()();
TextColumn get data => text()();
BoolColumn get isSaved => boolean().withDefault(const Constant(false))();
DateTimeColumn get memoryAt => dateTime()();
DateTimeColumn get seenAt => dateTime().nullable()();
DateTimeColumn get showAt => dateTime().nullable()();
DateTimeColumn get hideAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}

Binary file not shown.

View file

@ -0,0 +1,17 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class MemoryAssetEntity extends Table with DriftDefaultsMixin {
const MemoryAssetEntity();
TextColumn get assetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get memoryId =>
text().references(MemoryEntity, #id, onDelete: KeyAction.cascade)();
@override
Set<Column> get primaryKey => {assetId, memoryId};
}

Binary file not shown.

View file

@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
@ -46,6 +48,8 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumEntity,
RemoteAlbumAssetEntity,
RemoteAlbumUserEntity,
MemoryEntity,
MemoryAssetEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',

View file

@ -0,0 +1,37 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftMemoryRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftMemoryRepository(this._db) : super(_db);
Future<List<Memory>> getAll(String userId) {
final query = _db.memoryEntity.select()
..where((e) => e.ownerId.equals(userId));
return query.map((memory) {
return memory.toDto();
}).get();
}
}
extension on MemoryEntityData {
Memory toDto() {
return Memory(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
ownerId: ownerId,
type: type,
data: MemoryData.fromJson(data),
isSaved: isSaved,
memoryAt: memoryAt,
seenAt: seenAt,
showAt: showAt,
hideAt: hideAt,
);
}
}

View file

@ -52,6 +52,8 @@ class SyncApiRepository {
SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
SyncRequestType.memoryToAssetsV1,
],
).toJson(),
);
@ -157,6 +159,10 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson,
SyncEntityType.syncAckV1: _SyncAckV1.fromJson,
SyncEntityType.memoryV1: SyncMemoryV1.fromJson,
SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson,
SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson,
SyncEntityType.memoryToAssetDeleteV1: SyncMemoryAssetDeleteV1.fromJson,
};
class _SyncAckV1 {

View file

@ -1,12 +1,17 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:logging/logging.dart';
@ -64,8 +69,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: SyncPartnerDeleteV1', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: SyncPartnerDeleteV1', error, stackTrace);
rethrow;
}
}
@ -87,8 +92,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: SyncPartnerV1', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: SyncPartnerV1', error, stackTrace);
rethrow;
}
}
@ -98,10 +103,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
String debugLabel = 'user',
}) async {
try {
await _db.remoteAssetEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
} catch (e, s) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', e, s);
await _db.remoteAssetEntity.deleteWhere(
(row) => row.id.isIn(data.map((error) => error.assetId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stackTrace);
rethrow;
}
}
@ -136,8 +142,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAssetsV1 - $debugLabel', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: updateAssetsV1 - $debugLabel', error, stackTrace);
rethrow;
}
}
@ -180,18 +186,23 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAssetsExifV1 - $debugLabel', e, s);
} catch (error, stackTrace) {
_logger.severe(
'Error: updateAssetsExifV1 - $debugLabel',
error,
stackTrace,
);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.remoteAlbumEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
} catch (e, s) {
_logger.severe('Error: deleteAlbumsV1', e, s);
await _db.remoteAlbumEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.albumId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumsV1', error, stackTrace);
rethrow;
}
}
@ -218,8 +229,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumsV1', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: updateAlbumsV1', error, stackTrace);
rethrow;
}
}
@ -237,8 +248,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: deleteAlbumUsersV1', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumUsersV1', error, stackTrace);
rethrow;
}
}
@ -264,8 +275,12 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumUsersV1 - $debugLabel', e, s);
} catch (error, stackTrace) {
_logger.severe(
'Error: updateAlbumUsersV1 - $debugLabel',
error,
stackTrace,
);
rethrow;
}
}
@ -285,8 +300,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: deleteAlbumToAssetsV1', e, s);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumToAssetsV1', error, stackTrace);
rethrow;
}
}
@ -310,8 +325,96 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumToAssetsV1 - $debugLabel', e, s);
} catch (error, stackTrace) {
_logger.severe(
'Error: updateAlbumToAssetsV1 - $debugLabel',
error,
stackTrace,
);
rethrow;
}
}
Future<void> updateMemoriesV1(Iterable<SyncMemoryV1> data) async {
try {
await _db.batch((batch) {
for (final memory in data) {
final companion = MemoryEntityCompanion(
createdAt: Value(memory.createdAt),
deletedAt: Value(memory.deletedAt),
ownerId: Value(memory.ownerId),
type: Value(memory.type.toMemoryType()),
data: Value(jsonEncode(memory.data)),
isSaved: Value(memory.isSaved),
memoryAt: Value(memory.memoryAt),
seenAt: Value.absentIfNull(memory.seenAt),
showAt: Value.absentIfNull(memory.showAt),
hideAt: Value.absentIfNull(memory.hideAt),
);
batch.insert(
_db.memoryEntity,
companion.copyWith(id: Value(memory.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateMemoriesV1', error, stackTrace);
rethrow;
}
}
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
try {
await _db.memoryEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.memoryId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteMemoriesV1', error, stackTrace);
rethrow;
}
}
Future<void> updateMemoryAssetsV1(Iterable<SyncMemoryAssetV1> data) async {
try {
await _db.batch((batch) {
for (final asset in data) {
final companion = MemoryAssetEntityCompanion(
memoryId: Value(asset.memoryId),
assetId: Value(asset.assetId),
);
batch.insert(
_db.memoryAssetEntity,
companion,
onConflict: DoNothing(),
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateMemoryAssetsV1', error, stackTrace);
rethrow;
}
}
Future<void> deleteMemoryAssetsV1(
Iterable<SyncMemoryAssetDeleteV1> data,
) async {
try {
await _db.batch((batch) {
for (final asset in data) {
batch.delete(
_db.memoryAssetEntity,
MemoryAssetEntityCompanion(
memoryId: Value(asset.memoryId),
assetId: Value(asset.assetId),
),
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: deleteMemoryAssetsV1', error, stackTrace);
rethrow;
}
}
@ -335,6 +438,13 @@ extension on AssetOrder {
};
}
extension on MemoryType {
MemoryTypeEnum toMemoryType() => switch (this) {
MemoryType.onThisDay => MemoryTypeEnum.onThisDay,
_ => throw Exception('Unknown MemoryType value: $this'),
};
}
extension on api.AlbumUserRole {
AlbumUserRole toAlbumUserRole() => switch (this) {
api.AlbumUserRole.editor => AlbumUserRole.editor,
@ -357,7 +467,7 @@ extension on String {
Duration? toDuration() {
try {
final parts = split(':')
.map((e) => double.parse(e).toInt())
.map((error) => double.parse(error).toInt())
.toList(growable: false);
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);

View file

@ -154,6 +154,14 @@ final _remoteStats = [
name: 'Remote Albums',
load: (db) => db.managers.remoteAlbumEntity.count(),
),
_Stat(
name: 'Memories',
load: (db) => db.managers.memoryEntity.count(),
),
_Stat(
name: 'Memories Assets',
load: (db) => db.managers.memoryAssetEntity.count(),
),
];
@RoutePage()

View file

@ -1,5 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/services/memory.service.dart';
final memoryFutureProvider =
@ -8,3 +10,7 @@ final memoryFutureProvider =
return await service.getMemoryLane();
});
final driftMemoryProvider = Provider<DriftMemoryRepository>(
(ref) => DriftMemoryRepository(ref.watch(driftProvider)),
);

View file

@ -81,6 +81,14 @@ void main() {
debugLabel: any(named: 'debugLabel'),
),
).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateMemoriesV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteMemoriesV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateMemoryAssetsV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any()))
.thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
@ -227,5 +235,94 @@ void main() {
verify(() => mockSyncApiRepo.ack(["2"])).called(1);
},
);
test("processes memory sync events successfully", () async {
final events = [
SyncStreamStub.memoryV1,
SyncStreamStub.memoryDeleteV1,
SyncStreamStub.memoryToAssetV1,
SyncStreamStub.memoryToAssetDeleteV1,
];
await simulateEvents(events);
verifyInOrder([
() => mockSyncStreamRepo.updateMemoriesV1(any()),
() => mockSyncApiRepo.ack(["5"]),
() => mockSyncStreamRepo.deleteMemoriesV1(any()),
() => mockSyncApiRepo.ack(["6"]),
() => mockSyncStreamRepo.updateMemoryAssetsV1(any()),
() => mockSyncApiRepo.ack(["7"]),
() => mockSyncStreamRepo.deleteMemoryAssetsV1(any()),
() => mockSyncApiRepo.ack(["8"]),
]);
verifyNever(() => mockAbortCallbackWrapper());
});
test("processes mixed memory and user events in correct order", () async {
final events = [
SyncStreamStub.memoryDeleteV1,
SyncStreamStub.userV1Admin,
SyncStreamStub.memoryToAssetV1,
SyncStreamStub.memoryV1,
];
await simulateEvents(events);
verifyInOrder([
() => mockSyncStreamRepo.deleteMemoriesV1(any()),
() => mockSyncApiRepo.ack(["6"]),
() => mockSyncStreamRepo.updateUsersV1(any()),
() => mockSyncApiRepo.ack(["1"]),
() => mockSyncStreamRepo.updateMemoryAssetsV1(any()),
() => mockSyncApiRepo.ack(["7"]),
() => mockSyncStreamRepo.updateMemoriesV1(any()),
() => mockSyncApiRepo.ack(["5"]),
]);
verifyNever(() => mockAbortCallbackWrapper());
});
test("handles memory sync failure gracefully", () async {
when(() => mockSyncStreamRepo.updateMemoriesV1(any()))
.thenThrow(Exception("Memory sync failed"));
final events = [
SyncStreamStub.memoryV1,
SyncStreamStub.userV1Admin,
];
expect(
() async => await simulateEvents(events),
throwsA(isA<Exception>()),
);
});
test("processes memory asset events with correct data types", () async {
final events = [SyncStreamStub.memoryToAssetV1];
await simulateEvents(events);
verify(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())).called(1);
verify(() => mockSyncApiRepo.ack(["7"])).called(1);
});
test("processes memory delete events with correct data types", () async {
final events = [SyncStreamStub.memoryDeleteV1];
await simulateEvents(events);
verify(() => mockSyncStreamRepo.deleteMemoriesV1(any())).called(1);
verify(() => mockSyncApiRepo.ack(["6"])).called(1);
});
test("processes memory create/update events with correct data types",
() async {
final events = [SyncStreamStub.memoryV1];
await simulateEvents(events);
verify(() => mockSyncStreamRepo.updateMemoriesV1(any())).called(1);
verify(() => mockSyncApiRepo.ack(["5"])).called(1);
});
});
}

View file

@ -42,4 +42,47 @@ abstract final class SyncStreamStub {
data: SyncPartnerDeleteV1(sharedById: "3", sharedWithId: "4"),
ack: "4",
);
static final memoryV1 = SyncEvent(
type: SyncEntityType.memoryV1,
data: SyncMemoryV1(
createdAt: DateTime(2023, 1, 1),
data: {"year": 2023, "title": "Test Memory"},
deletedAt: null,
hideAt: null,
id: "memory-1",
isSaved: false,
memoryAt: DateTime(2023, 1, 1),
ownerId: "user-1",
seenAt: null,
showAt: DateTime(2023, 1, 1),
type: MemoryType.onThisDay,
updatedAt: DateTime(2023, 1, 1),
),
ack: "5",
);
static final memoryDeleteV1 = SyncEvent(
type: SyncEntityType.memoryDeleteV1,
data: SyncMemoryDeleteV1(memoryId: "memory-2"),
ack: "6",
);
static final memoryToAssetV1 = SyncEvent(
type: SyncEntityType.memoryToAssetV1,
data: SyncMemoryAssetV1(
assetId: "asset-1",
memoryId: "memory-1",
),
ack: "7",
);
static final memoryToAssetDeleteV1 = SyncEvent(
type: SyncEntityType.memoryToAssetDeleteV1,
data: SyncMemoryAssetDeleteV1(
assetId: "asset-2",
memoryId: "memory-1",
),
ack: "8",
);
}