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({}); }); 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: ['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('upserts inferred preference signals and updates status', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final LocalDataState state = await container.read( localRepositoryProvider.future, ); final String personId = state.people.first.id; final PersonPreferenceSignal first = await container .read(localRepositoryProvider.notifier) .upsertInferredPreferenceSignalObservation( personId: personId, key: 'drink:tea', label: 'Tea', category: 'drink', polarity: PreferenceSignalPolarity.like, confidence: 0.7, sourceApp: 'whatsapp', evidenceMessageId: 'sm-1', evidenceSnippet: 'I prefer tea over coffee in the evening.', ); final PersonPreferenceSignal second = await container .read(localRepositoryProvider.notifier) .upsertInferredPreferenceSignalObservation( personId: personId, key: 'drink:tea', label: 'Tea', category: 'drink', polarity: PreferenceSignalPolarity.like, confidence: 0.9, sourceApp: 'imessage', evidenceMessageId: 'sm-2', evidenceSnippet: 'Tea sounds perfect after dinner.', ); final LocalDataState afterUpsert = await container.read( localRepositoryProvider.future, ); final PersonPreferenceSignal signal = afterUpsert.preferenceSignals .firstWhere((PersonPreferenceSignal item) => item.id == first.id); expect(first.id, second.id); expect(signal.personId, personId); expect(signal.occurrenceCount, 2); expect(signal.status, PreferenceSignalStatus.inferred); expect(signal.confidence, closeTo(0.8, 0.001)); expect(signal.sourceApps, containsAll(['whatsapp', 'imessage'])); expect(signal.evidenceMessageIds, containsAll(['sm-1', 'sm-2'])); await container .read(localRepositoryProvider.notifier) .confirmPreferenceSignal(signal.id); final LocalDataState afterConfirm = await container.read( localRepositoryProvider.future, ); expect( afterConfirm.preferenceSignals .firstWhere((PersonPreferenceSignal item) => item.id == signal.id) .status, PreferenceSignalStatus.confirmed, ); await container .read(localRepositoryProvider.notifier) .dismissPreferenceSignal(signal.id); final LocalDataState afterDismiss = await container.read( localRepositoryProvider.future, ); expect( afterDismiss.preferenceSignals .firstWhere((PersonPreferenceSignal item) => item.id == signal.id) .status, PreferenceSignalStatus.dismissed, ); }); 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); final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere( (SourceProfileLink link) => link.profileId == first.profileId, ); expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn'); 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( 'ingestSharedMessage extracts chat-derived preference signals', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); await container.read(localRepositoryProvider.future); final SharedMessageIngestResult result = await container .read(localRepositoryProvider.notifier) .ingestSharedMessage( const SharedMessageIngestInput( sourceApp: 'whatsapp', sourceDisplayName: 'Nora Diaz', sourceUserId: 'wa:nora_001', messageText: "I prefer tea over coffee and I don't like loud clubs.", ), ); final LocalDataState after = await container.read( localRepositoryProvider.future, ); final List signals = after.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.personId == result.profileId, ) .toList(growable: false); expect(signals, isNotEmpty); expect( signals.any( (PersonPreferenceSignal signal) => signal.key == 'drink:tea' && signal.polarity == PreferenceSignalPolarity.like && signal.status == PreferenceSignalStatus.inferred, ), isTrue, ); expect( signals.any( (PersonPreferenceSignal signal) => signal.key == 'drink:coffee' && signal.polarity == PreferenceSignalPolarity.dislike, ), isTrue, ); }, ); test( 'queues near-match conflict and then uses fingerprint mapping for follow-up shares', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); await container.read(localRepositoryProvider.future); await container .read(localRepositoryProvider.notifier) .addPerson( name: 'Marek Havel', relationship: 'Friend', notes: '', tags: const [], ); final LocalDataState peopleState = await container.read( localRepositoryProvider.future, ); final String marekId = peopleState.people .firstWhere((PersonProfile person) => person.name == 'Marek Havel') .id; final SharedMessageIngestResult queued = await container .read(localRepositoryProvider.notifier) .ingestSharedMessage( const SharedMessageIngestInput( sourceApp: 'whatsapp', sourceDisplayName: 'Marek Havl', sourceUserId: 'wa:marek_882', messageText: 'Saw this and thought of you.', ), ); LocalDataState afterQueue = await container.read( localRepositoryProvider.future, ); expect(queued.isQueuedForResolution, isTrue); expect( afterQueue.sharedInbox.first.reason, SharedInboxReason.nearProfileConflict, ); expect( afterQueue.sharedInbox.first.candidateProfileIds, contains(marekId), ); final SharedMessageIngestResult resolved = await container .read(localRepositoryProvider.notifier) .resolveSharedInboxToExistingProfile( inboxEntryId: queued.inboxEntryId!, profileId: marekId, ); expect(resolved.profileId, marekId); final SharedMessageIngestResult followUp = await container .read(localRepositoryProvider.notifier) .ingestSharedMessage( const SharedMessageIngestInput( sourceApp: 'whatsapp', sourceDisplayName: 'Alias Name', sourceUserId: 'wa:marek_882', messageText: 'Second follow-up message.', ), ); afterQueue = await container.read(localRepositoryProvider.future); expect(followUp.isQueuedForResolution, isFalse); expect(followUp.profileId, marekId); expect(afterQueue.sharedInbox, isEmpty); expect( afterQueue.sourceLinks.any( (SourceProfileLink link) => link.profileId == marekId && link.sourceFingerprint == 'whatsapp|u:wa:marek_882', ), isTrue, ); }, ); 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 [], ); await container .read(localRepositoryProvider.notifier) .addPerson( name: 'Alex Morgan', relationship: 'Cousin', notes: '', tags: const [], ); 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 [], ); await container .read(localRepositoryProvider.notifier) .addPerson( name: 'Alex Morgan', relationship: 'Cousin', notes: '', tags: const [], ); 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( 'mergePersonProfiles rebinds related records to target profile', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); await container.read(localRepositoryProvider.future); await container .read(localRepositoryProvider.notifier) .addPerson( name: 'Ava Hart', relationship: 'Partner', notes: 'Primary notes', tags: const ['coffee'], location: 'Austin', ); await container .read(localRepositoryProvider.notifier) .addPerson( name: 'Ava H.', relationship: 'Friend', notes: 'Secondary notes', tags: const ['books'], ); LocalDataState state = await container.read( localRepositoryProvider.future, ); final String targetId = state.people .firstWhere((PersonProfile person) => person.name == 'Ava Hart') .id; final String sourceId = state.people .firstWhere((PersonProfile person) => person.name == 'Ava H.') .id; await container .read(localRepositoryProvider.notifier) .addMoment(personId: sourceId, summary: 'Source profile moment'); await container .read(localRepositoryProvider.notifier) .addIdea( type: IdeaType.gift, title: 'Source profile idea', details: 'idea', personId: sourceId, ); await container .read(localRepositoryProvider.notifier) .addReminder( title: 'Source profile reminder', cadence: ReminderCadence.weekly, nextAt: DateTime.now().add(const Duration(days: 2)), personId: sourceId, ); await container .read(localRepositoryProvider.notifier) .ingestSharedMessage( const SharedMessageIngestInput( sourceApp: 'whatsapp', sourceDisplayName: 'Ava H.', sourceUserId: 'wa:ava_h', messageText: 'Source-linked shared message', ), ); await container .read(localRepositoryProvider.notifier) .mergePersonProfiles( sourcePersonId: sourceId, targetPersonId: targetId, ); state = await container.read(localRepositoryProvider.future); expect( state.people.any((PersonProfile person) => person.id == sourceId), isFalse, ); final PersonProfile merged = state.people.firstWhere( (PersonProfile person) => person.id == targetId, ); expect(merged.tags, containsAll(['coffee', 'books'])); expect(merged.notes, contains('Primary notes')); expect(merged.notes, contains('Secondary notes')); expect(merged.location, 'Austin'); expect( state.moments.any( (RelationshipMoment moment) => moment.personId == sourceId, ), isFalse, ); expect( state.ideas.any((RelationshipIdea idea) => idea.personId == sourceId), isFalse, ); expect( state.reminders.any( (ReminderRule reminder) => reminder.personId == sourceId, ), isFalse, ); expect( state.sourceLinks.any( (SourceProfileLink link) => link.sourceUserId == 'wa:ava_h' && link.profileId == targetId, ), isTrue, ); expect( state.sharedMessages.any( (SharedMessageEntry entry) => entry.sourceUserId == 'wa:ava_h' && entry.profileId == targetId, ), isTrue, ); }, ); 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 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> snapshots = >[]; @override Future clearAll() async {} @override Future requestPermissions() async => true; @override Future reconcile(List reminders) async { snapshots.add(List.unmodifiable(reminders)); } }