Refine backendless share intake

This commit is contained in:
Rijad Zuzo
2026-05-18 20:33:54 +02:00
parent f655adfbea
commit 42a59e959f
37 changed files with 1467 additions and 824 deletions
@@ -21,10 +21,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
final String personId = await _addTestPerson(container);
await container
.read(localRepositoryProvider.notifier)
@@ -65,10 +62,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
final int ideaCount = initial.ideas.length;
await container
@@ -112,3 +109,19 @@ ProviderContainer _createContainer() {
],
);
}
Future<String> _addTestPerson(ProviderContainer container) async {
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
return state.people.single.id;
}
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
@@ -73,6 +74,19 @@ void main() {
isSensitive: true,
),
],
aiSuggestionDrafts: <AiSuggestionDraft>[
AiSuggestionDraft(
id: 'ai-1',
personId: 'p-1',
kind: AiSuggestionKind.eventIdea,
title: 'Jazz concert downtown',
details: 'Should not need to repeat the details.',
suggestedAt: DateTime(2026, 5, 10),
confidence: 0.8,
status: AiSuggestionStatus.dismissed,
sourceRunId: 'digest-old',
),
],
);
final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder(
@@ -90,5 +104,9 @@ void main() {
expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text')));
expect(prompt, isNot(contains('Secret medical detail')));
expect(prompt, contains('priorSuggestions'));
expect(prompt, contains('jazz concert downtown'));
expect(prompt, contains('dismissed'));
expect(prompt, isNot(contains('Should not need to repeat the details.')));
});
}
@@ -48,6 +48,15 @@ void main() {
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>['tea'],
);
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
final LlmDigestRunResult result = await container
+1 -1
View File
@@ -40,7 +40,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Sign In'));
await tester.pumpAndSettle(const Duration(milliseconds: 450));
expect(find.text('Dashboard'), findsWidgets);
expect(find.text('Capture'), findsWidgets);
await tester.tap(find.text('Settings').first);
await tester.pumpAndSettle(const Duration(milliseconds: 450));
@@ -47,7 +47,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Sign In'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Dashboard'), findsWidgets);
expect(find.text('Capture'), findsWidgets);
await tester.tap(find.text('People').last);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
@@ -91,13 +91,5 @@ void main() {
}),
isTrue,
);
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle(const Duration(milliseconds: 250));
await tester.tap(find.text('Sync').last);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Queue Status'), findsOneWidget);
expect(find.text('Pending changes'), findsOneWidget);
});
}
@@ -25,13 +25,23 @@ void main() {
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Ava Hart',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState seeded = await container.read(
localRepositoryProvider.future,
);
final String personId = seeded.people.first.id;
await container
.read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation(
personId: seeded.people.first.id,
personId: personId,
key: 'drink:tea',
label: 'Tea',
category: 'drink',
@@ -58,7 +68,7 @@ void main() {
expect(find.text('Explore'), findsOneWidget);
final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'),
ValueKey<String>('graph-node-$personId'),
);
expect(avaNode, findsOneWidget);
+92 -32
View File
@@ -17,15 +17,17 @@ void main() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('seeds local data on first load', () async {
test('starts with empty 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);
expect(state.people, isEmpty);
expect(state.moments, isEmpty);
expect(state.ideas, isEmpty);
expect(state.reminders, isEmpty);
expect(state.tasks, isEmpty);
});
test('adds and removes person with persistence state update', () async {
@@ -62,8 +64,8 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final state = await container.read(localRepositoryProvider.future);
final String personId = state.people.first.id;
final int beforeCount = state.moments.length;
await container
@@ -86,10 +88,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final String personId = await _addTestPerson(container);
final PersonPreferenceSignal first = await container
.read(localRepositoryProvider.notifier)
@@ -161,7 +160,7 @@ void main() {
});
test(
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages',
'ingestSharedMessage queues unknown identity, then reuses source link after manual creation',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
@@ -184,22 +183,35 @@ void main() {
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(first.isQueuedForResolution, isTrue);
expect(first.createdProfile, isFalse);
expect(afterFirst.people.length, before.people.length);
expect(
afterFirst.sharedInbox.single.reason,
SharedInboxReason.missingIdentity,
);
final SharedMessageIngestResult created = await container
.read(localRepositoryProvider.notifier)
.resolveSharedInboxByCreatingProfile(
inboxEntryId: first.inboxEntryId!,
name: 'Taylor Quinn',
relationship: 'Friend',
);
final LocalDataState afterCreate = await container.read(
localRepositoryProvider.future,
);
expect(created.createdProfile, isTrue);
expect(afterCreate.people.length, before.people.length + 1);
final SourceProfileLink createdLink = afterCreate.sourceLinks.firstWhere(
(SourceProfileLink link) => link.profileId == created.profileId,
);
expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn');
expect(
afterFirst.sourceLinks.any(
(SourceProfileLink link) => link.profileId == first.profileId,
),
isTrue,
);
expect(
afterFirst.moments.any(
afterCreate.moments.any(
(RelationshipMoment moment) =>
moment.personId == first.profileId && moment.type == 'whatsapp',
moment.personId == created.profileId && moment.type == 'whatsapp',
),
isTrue,
);
@@ -219,11 +231,11 @@ void main() {
localRepositoryProvider.future,
);
expect(second.createdProfile, isFalse);
expect(second.profileId, first.profileId);
expect(afterSecond.people.length, afterFirst.people.length);
expect(second.profileId, created.profileId);
expect(afterSecond.people.length, afterCreate.people.length);
expect(
afterSecond.sharedMessages.length,
afterFirst.sharedMessages.length + 1,
afterCreate.sharedMessages.length + 1,
);
},
);
@@ -234,7 +246,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await _addTestPerson(container, name: 'Nora Diaz');
final SharedMessageIngestResult result = await container
.read(localRepositoryProvider.notifier)
@@ -285,10 +297,7 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final String personId = await _addTestPerson(container);
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'She loves vegan sushi and rooftop dinners.',
sourceApp: 'share_sheet',
@@ -378,6 +387,35 @@ void main() {
},
);
test(
'captureSharedPayloadByCreatingProfile requires an explicit person name',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
final SharedPayload payload = SharePayloadParser.parse(
rawText: 'Alex: Kaffee klingt gut',
sourceApp: 'whatsapp',
platform: 'ios',
);
expect(payload.sourceDisplayName, 'Alex');
expect(
() => container
.read(localRepositoryProvider.notifier)
.captureSharedPayloadByCreatingProfile(payload: payload),
throwsArgumentError,
);
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(after.people, isEmpty);
expect(after.sharedMessages, isEmpty);
},
);
test(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async {
@@ -689,10 +727,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.ideas.length;
await container
@@ -740,10 +778,10 @@ void main() {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final String personId = await _addTestPerson(container);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final int beforeCount = state.reminders.length;
await container
@@ -852,6 +890,28 @@ ProviderContainer _createContainer({ReminderScheduler? scheduler}) {
);
}
Future<String> _addTestPerson(
ProviderContainer container, {
String name = 'Test Person',
String relationship = 'Friend',
}) async {
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: name,
relationship: relationship,
notes: '',
tags: const <String>[],
);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
return state.people
.firstWhere((PersonProfile person) => person.name == name)
.id;
}
class RecordingReminderScheduler implements ReminderScheduler {
final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[];
@@ -19,7 +19,7 @@ void main() {
addTearDown(container.dispose);
final initial = await container.read(localRepositoryProvider.future);
expect(initial.people, isNotEmpty);
expect(initial.people, isEmpty);
await container
.read(localRepositoryProvider.notifier)
@@ -30,6 +30,15 @@ void main() {
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Taylor Quinn',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
@@ -65,7 +65,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Share Inbox'), findsOneWidget);
expect(find.text('Capture'), findsOneWidget);
expect(find.text('Ambiguous match'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget);
@@ -10,7 +10,7 @@ import 'package:relationship_saver/features/sync/sync_coordinator.dart';
void main() {
testWidgets(
'triggers sync when reachability transitions from offline to online',
'does not trigger sync when background sync is disabled by default',
(WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: false);
addTearDown(reachability.dispose);
@@ -39,56 +39,56 @@ void main() {
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
// Startup trigger always runs once when background sync is enabled.
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(false);
await tester.pumpAndSettle(const Duration(milliseconds: 40));
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 2);
expect(syncCalls, 0);
},
);
testWidgets('does not trigger extra sync while connectivity stays online', (
WidgetTester tester,
) async {
final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose);
testWidgets(
'does not trigger sync while connectivity stays online by default',
(WidgetTester tester) async {
final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose);
int syncCalls = 0;
int syncCalls = 0;
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) => _TestSyncCoordinator(
ref,
onSyncNow: () async {
syncCalls += 1;
return _okResult();
},
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) => _TestSyncCoordinator(
ref,
onSyncNow: () async {
syncCalls += 1;
return _okResult();
},
),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
expect(syncCalls, 0);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
});
expect(syncCalls, 0);
},
);
}
class _TestSyncCoordinator extends SyncCoordinator {
@@ -111,6 +111,29 @@ void main() {
);
expect(queueState.cursor, 'cursor-remote-1');
});
test('default fake pull is inert and does not seed people', () async {
final ProviderContainer container = ProviderContainer(
overrides: [
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
final SyncRunResult result = await container
.read(syncCoordinatorProvider)
.pullRemote();
expect(result.success, isTrue);
expect(result.pulled, 0);
final localState = await container.read(localRepositoryProvider.future);
expect(localState.people, isEmpty);
});
}
class _TestGateway extends BackendGatewayFake {
@@ -23,6 +23,15 @@ void main() {
);
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Repair Person',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final localBefore = await container.read(localRepositoryProvider.future);
final person = localBefore.people.first;