leafittome/test/widget_test.dart
cschlaefke 0cc39f69b1 Fix: Bootstrap-Maßstab ist householdId, nicht users-Doc-Existenz
Die Push-Registrierung legt users/{uid} per merge selbst an (fcmTokens,
timezone) — beim kaputten Apple-Account existierte das Dokument daher
ohne householdId, und Gate + Bootstrap hielten den Account für fertig.
Jetzt prüfen beide auf householdId; der Bootstrap schreibt das
users-Dokument mit merge, damit die Push-Felder erhalten bleiben.
Neuer Test bildet exakt diesen Zustand ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:43:23 +02:00

362 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:leafittome/app.dart';
import 'package:leafittome/core/firebase/firebase_providers.dart';
import 'package:leafittome/core/settings/settings_provider.dart';
import 'package:leafittome/features/notifications/data/push_registration_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tests brauchen kein echtes FCM Push-Registrierung ist ein No-Op.
class _FakePushRegistrationService implements PushRegistrationService {
@override
Future<void> registerDevice(User user) async {}
@override
Future<void> syncReminderTime(TimeOfDay time) async {}
}
/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' als Besitzer
/// des Haushalts 'h1' mit einer Monstera, deren Gießen überfällig ist.
/// Mit [secondHousehold] ist u1 zusätzlich Sitter im fremden Haushalt 'h2'
/// (Besitzer 'u2'), in dem ein Ficus fällig ist.
/// Mit [extraMember] hat 'h1' zusätzlich den Sitter 'u3'.
Future<Widget> buildTestApp({
String role = 'member',
bool secondHousehold = false,
bool extraMember = false,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'ownerUid': 'u1',
'memberUids': ['u1', if (extraMember) 'u3'],
'members': {
'u1': {'role': role, 'email': 'test@example.com'},
if (extraMember) 'u3': {'role': 'sitter', 'email': 'sitter@example.com'},
},
});
if (secondHousehold) {
await firestore.collection('households').doc('h2').set({
'name': 'Zweiter Haushalt',
'ownerUid': 'u2',
'memberUids': ['u2', 'u1'],
'members': {
'u1': {'role': 'sitter', 'email': 'test@example.com'},
'u2': {'role': 'member', 'email': 'other@example.com'},
},
});
await firestore
.collection('households')
.doc('h2')
.collection('plants')
.add({
'nickname': 'Ficus',
'species': 'Ficus benjamina',
'locationId': null,
'description': '',
'careNotes': '',
'wateringIntervalDays': 7,
'fertilizingIntervalDays': 28,
'lastWatered': null,
'lastFertilized': Timestamp.fromDate(
DateTime.now().subtract(const Duration(days: 3))),
});
}
final now = DateTime.now();
await firestore
.collection('households')
.doc('h1')
.collection('plants')
.add({
'nickname': 'Monstera',
'species': 'Monstera deliciosa',
'locationId': null,
'description': '',
'careNotes': '',
'wateringIntervalDays': 7,
'fertilizingIntervalDays': 28,
'lastWatered':
Timestamp.fromDate(now.subtract(const Duration(days: 8))),
'lastFertilized':
Timestamp.fromDate(now.subtract(const Duration(days: 3))),
});
return ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
);
}
void main() {
testWidgets('Angemeldeter Nutzer sieht die Heute-Checkliste mit fälliger Aufgabe',
(tester) async {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();
expect(find.text('Heute'), findsOneWidget);
expect(find.textContaining('offene Aufgabe'), findsOneWidget);
expect(find.textContaining('Monstera gießen'), findsOneWidget);
});
testWidgets('Aufgabe bestätigen schreibt nach Firestore und leert die Liste',
(tester) async {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Erledigt').first);
await tester.pumpAndSettle();
expect(find.text('Erledigt'), findsNothing);
expect(find.text('Alles versorgt!'), findsOneWidget);
});
testWidgets('Sitter kann abhaken, sieht aber keinen Anlegen-Button',
(tester) async {
await tester.pumpWidget(await buildTestApp(role: 'sitter'));
await tester.pumpAndSettle();
// Abhaken ist erlaubt …
expect(find.text('Erledigt'), findsWidgets);
// … aber Pflanzen anlegen nicht.
expect(find.text('Pflanze hinzufügen'), findsNothing);
});
testWidgets('Mitglied mehrerer Haushalte kann den aktiven Haushalt wechseln',
(tester) async {
await tester.pumpWidget(await buildTestApp(secondHousehold: true));
await tester.pumpAndSettle();
// Über das Menü zur Haushalts-Ansicht.
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Haushalt'));
await tester.pumpAndSettle();
// Beide Haushalte sind gelistet, h1 ist aktiv, Besitzer sind ausgewiesen.
expect(find.text('Meine Haushalte'), findsOneWidget);
expect(find.text('Aktiv'), findsOneWidget);
expect(find.text('Zweiter Haushalt'), findsOneWidget);
expect(find.text('Dein Haushalt'), findsOneWidget);
expect(find.text('Besitzer: other@example.com'), findsOneWidget);
// Wechsel nach h2: Zeiger in users/u1 wird umgestellt …
await tester.tap(find.text('Zweiter Haushalt'));
await tester.pumpAndSettle();
// … und die Ansicht zeigt den neuen Haushalt (AppBar + Listeneintrag).
expect(find.text('Zweiter Haushalt'), findsNWidgets(2));
expect(find.text('other@example.com'), findsOneWidget);
// Im fremden Haushalt (nicht Besitzer): Austreten möglich, Entfernen nicht.
expect(find.text('Haushalt verlassen'), findsOneWidget);
expect(find.byIcon(Icons.person_remove), findsNothing);
});
testWidgets('Checkliste gruppiert Aufgaben aus mehreren Haushalten',
(tester) async {
await tester.pumpWidget(await buildTestApp(secondHousehold: true));
await tester.pumpAndSettle();
// Aufgaben beider Haushalte mit Zwischenüberschriften.
expect(find.textContaining('Monstera gießen'), findsOneWidget);
expect(find.textContaining('Ficus gießen'), findsOneWidget);
expect(find.text('Mein Haushalt'), findsOneWidget);
expect(find.text('Zweiter Haushalt'), findsOneWidget);
// Die Ficus-Aufgabe (zweite Gruppe) bestätigen → sie schreibt in den
// richtigen Haushalt, die h2-Gruppe verschwindet mitsamt Überschriften.
await tester.tap(find.text('Erledigt').last);
await tester.pumpAndSettle();
expect(find.textContaining('Ficus gießen'), findsNothing);
expect(find.textContaining('Monstera gießen'), findsOneWidget);
expect(find.text('Zweiter Haushalt'), findsNothing);
});
testWidgets('Besitzer sieht Besitzer-Kennzeichnung und kann entfernen, '
'aber nicht austreten', (tester) async {
await tester.pumpWidget(await buildTestApp(extraMember: true));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Haushalt'));
await tester.pumpAndSettle();
// Besitzer-Kennzeichnung am eigenen Eintrag, Entfernen-Knopf beim Sitter.
expect(find.text('Mitglied · Besitzer'), findsOneWidget);
expect(find.byIcon(Icons.person_remove), findsOneWidget);
expect(find.text('Haushalt verlassen'), findsNothing);
// Entfernen fragt nach.
await tester.tap(find.byIcon(Icons.person_remove));
await tester.pumpAndSettle();
expect(find.text('Mitglied entfernen?'), findsOneWidget);
});
testWidgets(
'Alt-Haushalt ohne members-Eintrag: eigene E-Mail wird nachgetragen',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
// V1-Zeit: kein ownerUid, keine members-Map nur memberUids.
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'memberUids': ['u1'],
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
final doc = await firestore.collection('households').doc('h1').get();
final members = doc.data()?['members'] as Map<String, dynamic>?;
expect((members?['u1'] as Map<String, dynamic>?)?['email'],
'test@example.com');
expect((members?['u1'] as Map<String, dynamic>?)?['role'], 'member');
});
testWidgets(
'Erster Login ohne Profil (z. B. Apple): Haushalt wird reaktiv angelegt',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
// Angemeldet (wie nach signInWithProvider), aber Firestore ist leer
// genau der Zustand, in dem Pflanzen-Speichern an den Rules scheiterte.
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'apple1', email: 'apple@example.com'),
);
final firestore = FakeFirebaseFirestore();
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
// Das BootstrapGate hat Profil + Haushalt angelegt …
final userDoc = await firestore.collection('users').doc('apple1').get();
expect(userDoc.exists, isTrue);
final householdId = userDoc.data()?['householdId'] as String?;
expect(householdId, isNotNull);
final household =
await firestore.collection('households').doc(householdId).get();
expect(household.data()?['ownerUid'], 'apple1');
expect(household.data()?['memberUids'], ['apple1']);
expect(
((household.data()?['members'] as Map<String, dynamic>)['apple1']
as Map<String, dynamic>)['role'],
'member');
// … und die App ist normal nutzbar (Heute-Screen, keine Aufgaben).
expect(find.text('Heute'), findsOneWidget);
expect(find.text('Alles versorgt!'), findsOneWidget);
});
testWidgets(
'users-Doc existiert ohne householdId (Push-Registrierung war schneller): '
'Haushalt wird nachgezogen, Push-Felder bleiben erhalten',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'apple1', email: 'apple@example.com'),
);
final firestore = FakeFirebaseFirestore();
// Exakt der Zustand des kaputten Apple-Accounts: Die Push-Registrierung
// hat das users-Dokument angelegt, der Bootstrap kam nie durch.
await firestore.collection('users').doc('apple1').set({
'fcmTokens': ['token-1'],
'timezone': 'Europe/Berlin',
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
final userDoc = await firestore.collection('users').doc('apple1').get();
final householdId = userDoc.data()?['householdId'] as String?;
expect(householdId, isNotNull);
// Die Push-Felder wurden nicht überschrieben (merge statt set).
expect(userDoc.data()?['fcmTokens'], ['token-1']);
expect(userDoc.data()?['timezone'], 'Europe/Berlin');
final household =
await firestore.collection('households').doc(householdId).get();
expect(household.data()?['memberUids'], ['apple1']);
expect(find.text('Heute'), findsOneWidget);
});
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Haushalt'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Wohnung Chris');
await tester.tap(find.text('Speichern'));
await tester.pumpAndSettle();
// Der neue Name kommt live aus Firestore zurück (AppBar).
expect(find.text('Wohnung Chris'), findsOneWidget);
});
}