immich/mobile/test/domain/services/sync_stream_service_test.dart
shenlong 81ed54aa61
feat: user sync stream (#16862)
* refactor: user entity

* chore: rebase fixes

* refactor: remove int user Id

* refactor: migrate store userId from int to string

* refactor: rename uid to id

* feat: drift

* pr feedback

* refactor: move common overrides to mixin

* refactor: remove int user Id

* refactor: migrate store userId from int to string

* refactor: rename uid to id

* feat: user & partner sync stream

* pr changes

* refactor: sync service and add tests

* chore: remove generated change

* chore: move sync model

* rebase: convert string ids to byte uuids

* rebase

* add processing logs

* batch db calls

* rewrite isolate manager

* rewrite with worker_manager

* misc fixes

* add sync order test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-17 10:25:27 -05:00

444 lines
15 KiB
Dart

// ignore_for_file: avoid-unnecessary-futures, avoid-async-call-in-sync-function
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import 'package:worker_manager/worker_manager.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
class _CancellationWrapper {
const _CancellationWrapper();
bool isCancelled() => false;
}
class _MockCancellationWrapper extends Mock implements _CancellationWrapper {}
void main() {
late SyncStreamService sut;
late ISyncStreamRepository mockSyncStreamRepo;
late ISyncApiRepository mockSyncApiRepo;
late StreamController<List<SyncEvent>> streamController;
successHandler(Invocation _) async => true;
failureHandler(Invocation _) async => false;
setUp(() {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
streamController = StreamController<List<SyncEvent>>.broadcast();
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
);
// Default stream setup - emits one batch and closes
when(() => mockSyncApiRepo.getSyncEvents(any()))
.thenAnswer((_) => streamController.stream);
// Default ack setup
when(() => mockSyncApiRepo.ack(any())).thenAnswer((_) async => {});
// Register fallbacks for mocktail verification
registerFallbackValue(<SyncUserV1>[]);
registerFallbackValue(<SyncPartnerV1>[]);
registerFallbackValue(<SyncUserDeleteV1>[]);
registerFallbackValue(<SyncPartnerDeleteV1>[]);
// Default successful repository calls
when(() => mockSyncStreamRepo.updateUsersV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updatePartnerV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deletePartnerV1(any()))
.thenAnswer(successHandler);
});
tearDown(() async {
if (!streamController.isClosed) {
await streamController.close();
}
});
// Helper to trigger sync and add events to the stream
Future<void> triggerSyncAndEmit(List<SyncEvent> events) async {
final future = sut.syncUsers(); // Start listening
await Future.delayed(Duration.zero); // Allow listener to attach
if (!streamController.isClosed) {
streamController.add(events);
await streamController.close(); // Close after emitting
}
await future; // Wait for processing to complete
}
group("SyncStreamService", () {
test(
"completes successfully when stream emits data and handlers succeed",
() async {
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
final future = triggerSyncAndEmit(events);
await expectLater(future, completes);
// Verify ack includes last ack from each successfully handled type
verify(
() =>
mockSyncApiRepo.ack(any(that: containsAll(["5", "2", "4", "3"]))),
).called(1);
},
);
test("completes successfully when stream emits an error", () async {
when(() => mockSyncApiRepo.getSyncEvents(any()))
.thenAnswer((_) => Stream.error(Exception("Stream Error")));
// Should complete gracefully without throwing
await expectLater(sut.syncUsers(), throwsException);
verifyNever(() => mockSyncApiRepo.ack(any())); // No ack on stream error
});
test("throws when initial getSyncEvents call fails", () async {
final apiException = Exception("API Error");
when(() => mockSyncApiRepo.getSyncEvents(any())).thenThrow(apiException);
// Should rethrow the exception from the initial call
await expectLater(sut.syncUsers(), throwsA(apiException));
verifyNever(() => mockSyncApiRepo.ack(any()));
});
test(
"completes successfully when a repository handler throws an exception",
() async {
when(() => mockSyncStreamRepo.updateUsersV1(any()))
.thenThrow(Exception("Repo Error"));
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
// Should complete, but ack only for the successful types
await triggerSyncAndEmit(events);
// Only partner delete was successful by default setup
verify(() => mockSyncApiRepo.ack(["2", "4", "3"])).called(1);
},
);
test(
"completes successfully but sends no ack when all handlers fail",
() async {
when(() => mockSyncStreamRepo.updateUsersV1(any()))
.thenAnswer(failureHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer(failureHandler);
when(() => mockSyncStreamRepo.updatePartnerV1(any()))
.thenAnswer(failureHandler);
when(() => mockSyncStreamRepo.deletePartnerV1(any()))
.thenAnswer(failureHandler);
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
await triggerSyncAndEmit(events);
verifyNever(() => mockSyncApiRepo.ack(any()));
},
);
test("sends ack only for types where handler returns true", () async {
// Mock specific handlers: user update fails, user delete succeeds
when(() => mockSyncStreamRepo.updateUsersV1(any()))
.thenAnswer(failureHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer(successHandler);
// partner update fails, partner delete succeeds
when(() => mockSyncStreamRepo.updatePartnerV1(any()))
.thenAnswer(failureHandler);
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
await triggerSyncAndEmit(events);
// Expect ack only for userDeleteV1 (ack: "2") and partnerDeleteV1 (ack: "4")
verify(() => mockSyncApiRepo.ack(any(that: containsAll(["2", "4"]))))
.called(1);
});
test("does not process or ack when stream emits an empty list", () async {
final future = sut.syncUsers();
streamController.add([]); // Emit empty list
await streamController.close();
await future; // Wait for completion
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
verifyNever(() => mockSyncStreamRepo.deleteUsersV1(any()));
verifyNever(() => mockSyncStreamRepo.updatePartnerV1(any()));
verifyNever(() => mockSyncStreamRepo.deletePartnerV1(any()));
verifyNever(() => mockSyncApiRepo.ack(any()));
});
test("processes multiple batches sequentially using mutex", () async {
final completer1 = Completer<void>();
final completer2 = Completer<void>();
int callOrder = 0;
int handler1StartOrder = -1;
int handler2StartOrder = -1;
int handler1Calls = 0;
int handler2Calls = 0;
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer((_) async {
handler1Calls++;
handler1StartOrder = ++callOrder;
await completer1.future;
return true;
});
when(() => mockSyncStreamRepo.updatePartnerV1(any()))
.thenAnswer((_) async {
handler2Calls++;
handler2StartOrder = ++callOrder;
await completer2.future;
return true;
});
final batch1 = SyncStreamStub.userEvents;
final batch2 = SyncStreamStub.partnerEvents;
final syncFuture = sut.syncUsers();
await pumpEventQueue();
streamController.add(batch1);
await pumpEventQueue();
// Small delay to ensure the first handler starts
await Future.delayed(const Duration(milliseconds: 20));
expect(handler1StartOrder, 1, reason: "Handler 1 should start first");
expect(handler1Calls, 1);
streamController.add(batch2);
await pumpEventQueue();
// Small delay
await Future.delayed(const Duration(milliseconds: 20));
expect(handler2StartOrder, -1, reason: "Handler 2 should wait");
expect(handler2Calls, 0);
completer1.complete();
await pumpEventQueue(times: 40);
// Small delay to ensure the second handler starts
await Future.delayed(const Duration(milliseconds: 20));
expect(handler2StartOrder, 2, reason: "Handler 2 should start after H1");
expect(handler2Calls, 1);
completer2.complete();
await pumpEventQueue(times: 40);
// Small delay before closing the stream
await Future.delayed(const Duration(milliseconds: 20));
if (!streamController.isClosed) {
await streamController.close();
}
await pumpEventQueue(times: 40);
// Small delay to ensure the sync completes
await Future.delayed(const Duration(milliseconds: 20));
await syncFuture;
verify(() => mockSyncStreamRepo.updateUsersV1(any())).called(1);
verify(() => mockSyncStreamRepo.updatePartnerV1(any())).called(1);
verify(() => mockSyncApiRepo.ack(any())).called(2);
});
test(
"stops processing and ack when cancel checker is completed",
() async {
final cancellationChecker = _MockCancellationWrapper();
when(() => cancellationChecker.isCancelled()).thenAnswer((_) => false);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
cancelChecker: cancellationChecker.isCancelled,
);
final processingCompleter = Completer<void>();
bool handlerStarted = false;
// Make handler wait so we can cancel it mid-flight
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer((_) async {
handlerStarted = true;
await processingCompleter
.future; // Wait indefinitely until test completes it
return true;
});
final syncFuture = sut.syncUsers();
await pumpEventQueue(times: 30);
streamController.add(SyncStreamStub.userEvents);
// Ensure processing starts
await Future.delayed(const Duration(milliseconds: 10));
expect(handlerStarted, isTrue, reason: "Handler should have started");
when(() => cancellationChecker.isCancelled()).thenAnswer((_) => true);
// Allow cancellation logic to propagate
await Future.delayed(const Duration(milliseconds: 10));
// Complete the handler's completer after cancellation signal
// to ensure the cancellation logic itself isn't blocked by the handler.
processingCompleter.complete();
await expectLater(syncFuture, throwsA(isA<CanceledError>()));
// Verify that ack was NOT called because processing was cancelled
verifyNever(() => mockSyncApiRepo.ack(any()));
},
);
test("completes successfully when ack call throws an exception", () async {
when(() => mockSyncApiRepo.ack(any())).thenThrow(Exception("Ack Error"));
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
// Should still complete even if ack fails
await triggerSyncAndEmit(events);
verify(() => mockSyncApiRepo.ack(any()))
.called(1); // Verify ack was attempted
});
test("waits for processing to finish if onDone called early", () async {
final processingCompleter = Completer<void>();
bool handlerFinished = false;
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer((_) async {
await processingCompleter.future; // Wait inside handler
handlerFinished = true;
return true;
});
final syncFuture = sut.syncUsers();
// Allow listener to attach
// This is necessary to ensure the stream is ready to receive events
await Future.delayed(Duration.zero);
streamController.add(SyncStreamStub.userEvents); // Emit batch
await Future.delayed(
const Duration(milliseconds: 10),
); // Ensure processing starts
await streamController
.close(); // Close stream (triggers onDone internally)
await Future.delayed(
const Duration(milliseconds: 10),
); // Give onDone a chance to fire
// At this point, onDone was called, but processing is blocked
expect(handlerFinished, isFalse);
processingCompleter.complete(); // Allow processing to finish
await syncFuture; // Now the main future should complete
expect(handlerFinished, isTrue);
verify(() => mockSyncApiRepo.ack(any())).called(1);
});
test("processes events in the defined _kSyncTypeOrder", () async {
final future = sut.syncUsers();
await pumpEventQueue();
if (!streamController.isClosed) {
final events = [
SyncEvent(
type: SyncEntityType.partnerV1,
data: SyncStreamStub.partnerV1,
ack: "1",
), // Should be processed last
SyncEvent(
type: SyncEntityType.userV1,
data: SyncStreamStub.userV1Admin,
ack: "2",
), // Should be processed second
SyncEvent(
type: SyncEntityType.partnerDeleteV1,
data: SyncStreamStub.partnerDeleteV1,
ack: "3",
), // Should be processed third
SyncEvent(
type: SyncEntityType.userDeleteV1,
data: SyncStreamStub.userDeleteV1,
ack: "4",
), // Should be processed first
];
streamController.add(events);
await streamController.close();
}
await future;
verifyInOrder([
() => mockSyncStreamRepo.deleteUsersV1(any()),
() => mockSyncStreamRepo.updateUsersV1(any()),
() => mockSyncStreamRepo.deletePartnerV1(any()),
() => mockSyncStreamRepo.updatePartnerV1(any()),
// Verify ack happens after all processing
() => mockSyncApiRepo.ack(any()),
]);
});
});
group("syncUsers", () {
test("calls getSyncEvents with correct types", () async {
// Need to close the stream for the future to complete
final future = sut.syncUsers();
await streamController.close();
await future;
verify(
() => mockSyncApiRepo.getSyncEvents([
SyncRequestType.usersV1,
SyncRequestType.partnersV1,
]),
).called(1);
});
test("calls repository methods with correctly grouped data", () async {
final events = [
...SyncStreamStub.userEvents,
...SyncStreamStub.partnerEvents,
];
await triggerSyncAndEmit(events);
// Verify each handler was called with the correct list of data payloads
verify(
() => mockSyncStreamRepo.updateUsersV1(
[SyncStreamStub.userV1Admin, SyncStreamStub.userV1User],
),
).called(1);
verify(
() => mockSyncStreamRepo.deleteUsersV1([SyncStreamStub.userDeleteV1]),
).called(1);
verify(
() => mockSyncStreamRepo.updatePartnerV1([SyncStreamStub.partnerV1]),
).called(1);
verify(
() => mockSyncStreamRepo
.deletePartnerV1([SyncStreamStub.partnerDeleteV1]),
).called(1);
});
});
}