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>
This commit is contained in:
cschlaefke 2026-07-23 21:43:23 +02:00
parent 5b8a99a26e
commit 0cc39f69b1
5 changed files with 73 additions and 13 deletions

View file

@ -38,10 +38,12 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
- **Selbstheilend:** Der bereits kaputte Apple-Account wird beim nächsten App-Start automatisch repariert (Gate sieht „kein Profil" → legt Haushalt an).
- Analyzer grün, alle 9 Tests grün (neuer Test: „Erster Login ohne Profil → Haushalt wird reaktiv angelegt").
**Nachbesserung (gleicher Tag, nach erstem Fehlversuch des Nutzers):** Mit dem Gate-Build schlug Speichern **weiter** fehl, und zwar ohne dass der Einrichtungs-Bildschirm je erschien (mit und ohne Foto). Befund: Die **Push-Registrierung** (`_saveToken`, `set(..., merge: true)`) hatte das `users`-Dokument beim ersten Apple-Login längst angelegt (fcmTokens/timezone, aber ohne `householdId`) — Gate und Bootstrap prüften nur die **Dokument-Existenz** und hielten den Account deshalb für fertig. Fix: Überall ist jetzt **`householdId` vorhanden** der Maßstab (Gate, Bootstrap-Provider, `_bootstrapHouseholdIfNeeded`), und der Bootstrap schreibt das users-Dokument mit **merge**, damit Push-Felder erhalten bleiben. Außerdem zeigt die Speichern-Fehlermeldung jetzt den konkreten Firebase-Fehlercode an (plugin/code statt Generik). Neuer Test stellt exakt den kaputten Zustand nach (users-Doc nur mit fcmTokens) — alle 10 Tests grün.
**Was der Nutzer jetzt testen sollte:**
1. App auf dem iPhone neu bauen/starten, mit dem **bestehenden Apple-Account** anmelden → kurzer „Dein Haushalt wird eingerichtet …"-Bildschirm, danach sollte Pflanze-Anlegen (mit Foto!) funktionieren.
1. App auf dem iPhone neu bauen/starten, mit dem **bestehenden Apple-Account** anmelden → jetzt sollte kurz „Dein Haushalt wird eingerichtet …" erscheinen (diesmal wirklich, da das Gate jetzt auf `householdId` prüft), danach Pflanze-Anlegen mit und ohne Foto.
2. Optional Härtetest „echter Erst-Login": unter <https://appleid.apple.com> die Freigabe für „LeafItToMe" entfernen → nächster Login ist wieder „erster Login" → Ende-zu-Ende inkl. Pflanze mit Foto.
3. Falls es **weiter** fehlschlägt, obwohl der Haushalt angezeigt wird (Menü → Haushalt): dann liegt es doch an Storage-Rules/IAM — dann isolieren, ob Foto-Upload (Storage) oder Pflanze-Schreiben (Firestore) scheitert.
3. Falls es **weiter** fehlschlägt: Die Fehlermeldung nennt jetzt den Code — `firebase_storage/…` heißt Foto-Upload/Storage-Rules/IAM, `cloud_firestore/…` heißt Plant-Write/Firestore-Rules. Den Code melden, dann gezielt weiter.
### Zuletzt erledigt

View file

@ -43,7 +43,7 @@ class AuthRepository {
Future<void>? _bootstrapInFlight;
/// Legt Profil-Dokument und einen eigenen Haushalt an, falls für [uid]
/// noch keins existiert. Idempotent und gegen parallele Aufrufe geschützt
/// noch kein Haushalt existiert. Idempotent und gegen parallele Aufrufe geschützt
/// (Registrierung und reaktiver Bootstrap können sich überlappen)
/// ein bestehender Haushalt wird nie überschrieben.
Future<void> ensureHouseholdBootstrap(String uid, String? email) {
@ -54,9 +54,14 @@ class AuthRepository {
}
/// [email] kann bei E-Mail verbergen die Apple-Relay-Adresse sein.
///
/// Maßstab ist das Feld `householdId`, nicht die bloße Existenz des
/// users-Dokuments: Die Push-Registrierung legt das Dokument nebenbei an
/// (fcmTokens/timezone, merge), ohne dass ein Haushalt existiert.
Future<void> _bootstrapHouseholdIfNeeded(String uid, String? email) async {
final userRef = _firestore.collection('users').doc(uid);
if ((await userRef.get()).exists) return;
final existing = await userRef.get();
if (existing.data()?['householdId'] != null) return;
final safeEmail = email ?? '';
final householdRef = _firestore.collection('households').doc();
@ -74,11 +79,17 @@ class AuthRepository {
},
'createdAt': FieldValue.serverTimestamp(),
});
batch.set(userRef, {
'email': safeEmail,
'householdId': householdRef.id,
'createdAt': FieldValue.serverTimestamp(),
});
// merge: von der Push-Registrierung bereits geschriebene Felder
// (fcmTokens, timezone, reminderTime) bleiben erhalten.
batch.set(
userRef,
{
'email': safeEmail,
'householdId': householdRef.id,
'createdAt': FieldValue.serverTimestamp(),
},
SetOptions(merge: true),
);
await batch.commit();
}

View file

@ -33,11 +33,13 @@ class HouseholdBootstrap extends Notifier<AsyncValue<void>> {
final user = ref.watch(authStateProvider).value;
final profile = ref.watch(userProfileProvider).value;
// Nur aktiv, wenn der Snapshot sicher zum aktuellen Nutzer gehört und
// das Profil fehlt bei Kontowechsel hält .value kurz den alten Stand.
// der Haushalt fehlt bei Kontowechsel hält .value kurz den alten Stand.
// Maßstab ist householdId, nicht die Dokument-Existenz: die Push-
// Registrierung legt das users-Dokument auch ohne Haushalt an.
if (user == null ||
profile == null ||
profile.id != user.uid ||
profile.exists) {
profile.data()?['householdId'] != null) {
return const AsyncValue.data(null);
}
_run(user);

View file

@ -23,8 +23,11 @@ class BootstrapGate extends ConsumerWidget {
if (user == null) return child; // Login-Screen übernimmt.
final profile = ref.watch(userProfileProvider).value;
final profileReady =
profile != null && profile.id == user.uid && profile.exists;
// householdId statt bloßer Dokument-Existenz: die Push-Registrierung
// legt das users-Dokument auch ohne Haushalt an.
final profileReady = profile != null &&
profile.id == user.uid &&
profile.data()?['householdId'] != null;
if (profileReady) return child;
final bootstrap = ref.watch(householdBootstrapProvider);

View file

@ -299,6 +299,48 @@ void main() {
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();