Files
rely/test/features/local/local_repository_test.dart
T

422 lines
14 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('seeds local data on first load', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final state = await container.read(localRepositoryProvider.future);
expect(state.people, isNotEmpty);
expect(state.moments, isNotEmpty);
expect(state.tasks, isNotEmpty);
});
test('adds and removes person with persistence state update', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final initial = await container.read(localRepositoryProvider.future);
final int initialCount = initial.people.length;
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Test Person',
relationship: 'Friend',
notes: 'test',
tags: <String>['alpha'],
location: 'Berlin',
);
final afterAdd = await container.read(localRepositoryProvider.future);
expect(afterAdd.people.length, initialCount + 1);
expect(afterAdd.people.first.location, 'Berlin');
final String insertedId = afterAdd.people.first.id;
await container
.read(localRepositoryProvider.notifier)
.deletePerson(insertedId);
final afterDelete = await container.read(localRepositoryProvider.future);
expect(afterDelete.people.length, initialCount);
});
test('adds and removes capture moments', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final state = await container.read(localRepositoryProvider.future);
final String personId = state.people.first.id;
final int beforeCount = state.moments.length;
await container
.read(localRepositoryProvider.notifier)
.addMoment(personId: personId, summary: 'This is a new capture moment');
final withMoment = await container.read(localRepositoryProvider.future);
expect(withMoment.moments.length, beforeCount + 1);
final String momentId = withMoment.moments.first.id;
await container
.read(localRepositoryProvider.notifier)
.deleteMoment(momentId);
final afterDelete = await container.read(localRepositoryProvider.future);
expect(afterDelete.moments.length, beforeCount);
});
test(
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState before = await container.read(
localRepositoryProvider.future,
);
final SharedMessageIngestResult first = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Taylor Quinn',
sourceUserId: 'wa:taylor quinn',
messageText: 'Can we do coffee this week?',
),
);
final LocalDataState afterFirst = await container.read(
localRepositoryProvider.future,
);
expect(first.createdProfile, isTrue);
expect(afterFirst.people.length, before.people.length + 1);
expect(
afterFirst.sourceLinks.any(
(SourceProfileLink link) => link.profileId == first.profileId,
),
isTrue,
);
expect(
afterFirst.moments.any(
(RelationshipMoment moment) =>
moment.personId == first.profileId && moment.type == 'whatsapp',
),
isTrue,
);
final SharedMessageIngestResult second = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Taylor Quinn',
sourceUserId: 'wa:taylor quinn',
messageText: 'Lets plan Sunday brunch too.',
),
);
final LocalDataState afterSecond = await container.read(
localRepositoryProvider.future,
);
expect(second.createdProfile, isFalse);
expect(second.profileId, first.profileId);
expect(afterSecond.people.length, afterFirst.people.length);
expect(
afterSecond.sharedMessages.length,
afterFirst.sharedMessages.length + 1,
);
},
);
test(
'queues ambiguous shared message into inbox for manual resolution',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState before = await container.read(
localRepositoryProvider.future,
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Cousin',
notes: '',
tags: const <String>[],
);
final SharedMessageIngestResult result = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Alex Morgan',
messageText: 'Can we sync up tomorrow?',
),
);
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(result.isQueuedForResolution, isTrue);
expect(result.inboxEntryId, isNotNull);
expect(after.sharedInbox.length, 1);
expect(
after.sharedInbox.first.reason,
SharedInboxReason.ambiguousProfileMatch,
);
expect(after.moments.length, before.moments.length);
},
);
test('resolves inbox item to existing profile', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Cousin',
notes: '',
tags: const <String>[],
);
final SharedMessageIngestResult queued = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Alex Morgan',
messageText: 'Did you see the offer?',
),
);
final LocalDataState pending = await container.read(
localRepositoryProvider.future,
);
final String profileId = pending.people.first.id;
final SharedMessageIngestResult resolved = await container
.read(localRepositoryProvider.notifier)
.resolveSharedInboxToExistingProfile(
inboxEntryId: queued.inboxEntryId!,
profileId: profileId,
);
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(resolved.isQueuedForResolution, isFalse);
expect(resolved.profileId, profileId);
expect(after.sharedInbox, isEmpty);
expect(
after.moments.any(
(RelationshipMoment moment) => moment.personId == profileId,
),
isTrue,
);
expect(after.sharedMessages.first.resolvedAutomatically, isFalse);
});
test('adds, archives, edits, and deletes ideas', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.ideas.length;
await container
.read(localRepositoryProvider.notifier)
.addIdea(
type: IdeaType.gift,
title: 'Test idea',
details: 'some details',
personId: personId,
);
final LocalDataState afterAdd = await container.read(
localRepositoryProvider.future,
);
expect(afterAdd.ideas.length, beforeCount + 1);
final RelationshipIdea inserted = afterAdd.ideas.first;
await container
.read(localRepositoryProvider.notifier)
.toggleIdeaArchived(inserted.id);
LocalDataState afterArchive = await container.read(
localRepositoryProvider.future,
);
expect(afterArchive.ideas.first.isArchived, isTrue);
await container
.read(localRepositoryProvider.notifier)
.updateIdea(afterArchive.ideas.first.copyWith(title: 'Updated idea'));
afterArchive = await container.read(localRepositoryProvider.future);
expect(afterArchive.ideas.first.title, 'Updated idea');
await container
.read(localRepositoryProvider.notifier)
.deleteIdea(inserted.id);
final LocalDataState afterDelete = await container.read(
localRepositoryProvider.future,
);
expect(afterDelete.ideas.length, beforeCount);
});
test('adds, updates, toggles, and deletes reminders', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.reminders.length;
await container
.read(localRepositoryProvider.notifier)
.addReminder(
title: 'Test reminder',
cadence: ReminderCadence.weekly,
nextAt: DateTime.now().add(const Duration(days: 1)),
personId: personId,
);
LocalDataState afterAdd = await container.read(
localRepositoryProvider.future,
);
expect(afterAdd.reminders.length, beforeCount + 1);
final ReminderRule inserted = afterAdd.reminders.first;
await container
.read(localRepositoryProvider.notifier)
.toggleReminderEnabled(inserted.id);
afterAdd = await container.read(localRepositoryProvider.future);
expect(afterAdd.reminders.first.enabled, isFalse);
await container
.read(localRepositoryProvider.notifier)
.updateReminder(
afterAdd.reminders.first.copyWith(title: 'Updated reminder'),
);
afterAdd = await container.read(localRepositoryProvider.future);
expect(afterAdd.reminders.first.title, 'Updated reminder');
await container
.read(localRepositoryProvider.notifier)
.deleteReminder(inserted.id);
final LocalDataState afterDelete = await container.read(
localRepositoryProvider.future,
);
expect(afterDelete.reminders.length, beforeCount);
});
test('reconciles reminder schedule after state changes', () async {
final RecordingReminderScheduler scheduler = RecordingReminderScheduler();
final ProviderContainer container = _createContainer(scheduler: scheduler);
addTearDown(container.dispose);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final int beforeCount = initial.reminders.length;
await container
.read(localRepositoryProvider.notifier)
.addReminder(
title: 'Scheduled reminder',
cadence: ReminderCadence.daily,
nextAt: DateTime.now().add(const Duration(hours: 4)),
);
final LocalDataState afterAdd = await container.read(
localRepositoryProvider.future,
);
await container
.read(localRepositoryProvider.notifier)
.deleteReminder(afterAdd.reminders.first.id);
final LocalDataState afterDelete = await container.read(
localRepositoryProvider.future,
);
expect(scheduler.snapshots, isNotEmpty);
expect(scheduler.snapshots.first.length, beforeCount);
expect(
scheduler.snapshots.any(
(List<ReminderRule> r) => r.length == beforeCount + 1,
),
isTrue,
);
expect(scheduler.snapshots.last.length, afterDelete.reminders.length);
});
}
ProviderContainer _createContainer({ReminderScheduler? scheduler}) {
return ProviderContainer(
overrides: [
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
if (scheduler != null)
reminderSchedulerProvider.overrideWithValue(scheduler),
],
);
}
class RecordingReminderScheduler implements ReminderScheduler {
final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[];
@override
Future<void> clearAll() async {}
@override
Future<bool> requestPermissions() async => true;
@override
Future<void> reconcile(List<ReminderRule> reminders) async {
snapshots.add(List<ReminderRule>.unmodifiable(reminders));
}
}