hogar compartido: colaboracion entre cuentas sobre Firestore
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import MealMood
|
||||
|
||||
/// The pure half of the household sync: document ids, fingerprints and the
|
||||
/// model ↔ Firestore mapping. Anything touching the network stays out.
|
||||
final class HouseholdDocumentsTests: XCTestCase {
|
||||
|
||||
/// Held for the duration of the test: releasing the container tears the
|
||||
/// store off the coordinator and the next SwiftData call traps.
|
||||
private var container: ModelContainer!
|
||||
private var context: ModelContext!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
container = try ModelContainer(
|
||||
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
context = ModelContext(container)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
context = nil
|
||||
container = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Document ids
|
||||
|
||||
func testWeekKeyIsTheMondayDate() {
|
||||
let monday = Calendar(identifier: .gregorian)
|
||||
.date(from: DateComponents(year: 2026, month: 9, day: 14))!
|
||||
|
||||
XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-09-14")
|
||||
}
|
||||
|
||||
func testWeekKeyIsStableAcrossLocales() {
|
||||
let monday = Calendar(identifier: .gregorian)
|
||||
.date(from: DateComponents(year: 2026, month: 1, day: 5))!
|
||||
|
||||
// Two members with different phone languages must write the same
|
||||
// document, not one each.
|
||||
XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-01-05")
|
||||
}
|
||||
|
||||
func testSlotIdIsDerivedFromContent() {
|
||||
XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 5, mealType: "dinner"), "5-dinner")
|
||||
XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 0, mealType: "breakfast"), "0-breakfast")
|
||||
}
|
||||
|
||||
// MARK: - Fingerprints
|
||||
|
||||
func testFingerprintIgnoresKeyOrder() {
|
||||
let a: [String: Any] = ["name": "Tortilla", "isPriority": true, "sortOrder": 3]
|
||||
let b: [String: Any] = ["sortOrder": 3, "name": "Tortilla", "isPriority": true]
|
||||
|
||||
XCTAssertEqual(HouseholdDocuments.fingerprint(a), HouseholdDocuments.fingerprint(b))
|
||||
}
|
||||
|
||||
func testFingerprintChangesWithContent() {
|
||||
let dish = Dish(name: "Tortilla")
|
||||
context.insert(dish)
|
||||
let before = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish))
|
||||
|
||||
dish.name = "Tortilla de patatas"
|
||||
let after = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish))
|
||||
|
||||
XCTAssertNotEqual(before, after, "an edited dish must be detected as a local change")
|
||||
}
|
||||
|
||||
func testFingerprintDistinguishesBoolFromInt() {
|
||||
let asBool: [String: Any] = ["value": true]
|
||||
let asInt: [String: Any] = ["value": 1]
|
||||
|
||||
XCTAssertNotEqual(HouseholdDocuments.fingerprint(asBool), HouseholdDocuments.fingerprint(asInt))
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
func testDishSurvivesARoundTrip() {
|
||||
let tagId = UUID()
|
||||
let original = Dish(
|
||||
name: "Salmón al horno",
|
||||
descriptionText: "con limón",
|
||||
tagIds: [tagId],
|
||||
isPriority: true,
|
||||
ingredients: ["salmón", "limón"],
|
||||
fixedDayOfWeek: 4,
|
||||
fixedMealType: "dinner"
|
||||
)
|
||||
context.insert(original)
|
||||
|
||||
let copy = Dish(id: original.id, name: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.name, "Salmón al horno")
|
||||
XCTAssertEqual(copy.descriptionText, "con limón")
|
||||
XCTAssertEqual(copy.tagIds, [tagId])
|
||||
XCTAssertEqual(copy.ingredients, ["salmón", "limón"])
|
||||
XCTAssertTrue(copy.isPriority)
|
||||
XCTAssertEqual(copy.fixedDayOfWeek, 4)
|
||||
XCTAssertEqual(copy.fixedMealType, "dinner")
|
||||
XCTAssertEqual(
|
||||
HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: original)),
|
||||
HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: copy)),
|
||||
"a round trip must not look like a change to push"
|
||||
)
|
||||
}
|
||||
|
||||
func testTagRulesSurviveARoundTrip() {
|
||||
let original = Tag(
|
||||
name: "Pescado", nameEN: "Fish", color: "#4FC3F7",
|
||||
maxPerWeek: 2, noConsecutive: true, noDuplicateInDay: true,
|
||||
mealTypeRestriction: "dinner", dayRestriction: "weekdays",
|
||||
isDefault: false, sortOrder: 7
|
||||
)
|
||||
context.insert(original)
|
||||
|
||||
let copy = Tag(id: original.id, name: "", color: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.nameEN, "Fish")
|
||||
XCTAssertEqual(copy.maxPerWeek, 2)
|
||||
XCTAssertTrue(copy.noConsecutive)
|
||||
XCTAssertEqual(copy.mealTypeRestriction, "dinner")
|
||||
XCTAssertEqual(copy.dayRestriction, "weekdays", "day rules must travel or they'd silently reset")
|
||||
XCTAssertEqual(copy.sortOrder, 7)
|
||||
}
|
||||
|
||||
func testSlotSurvivesARoundTripWithoutTheCalendarEventId() {
|
||||
let original = MealSlot(dayOfWeek: 2, mealType: "lunch")
|
||||
original.dishId = UUID()
|
||||
original.secondaryDishId = UUID()
|
||||
original.isSkipped = true
|
||||
original.calendarEventId = "local-eventkit-id"
|
||||
context.insert(original)
|
||||
|
||||
let copy = MealSlot(dayOfWeek: 2, mealType: "lunch")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.dishId, original.dishId)
|
||||
XCTAssertEqual(copy.secondaryDishId, original.secondaryDishId)
|
||||
XCTAssertTrue(copy.isSkipped)
|
||||
// The EventKit id only means something on the device that created it.
|
||||
XCTAssertNil(copy.calendarEventId)
|
||||
XCTAssertNil(HouseholdDocuments.fields(for: original)["calendarEventId"])
|
||||
}
|
||||
|
||||
func testWeekPlanCarriesItsScheduleOverride() {
|
||||
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
||||
plan.includeWeekendsOverride = true
|
||||
plan.mealTypesOverrideRaw = "breakfast,dinner"
|
||||
plan.userRating = 1
|
||||
context.insert(plan)
|
||||
|
||||
let copy = WeekPlan(weekStartDate: plan.weekStartDate)
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: plan), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.includeWeekendsOverride, true)
|
||||
XCTAssertEqual(copy.mealTypesOverrideRaw, "breakfast,dinner")
|
||||
XCTAssertEqual(copy.userRating, 1)
|
||||
}
|
||||
|
||||
func testShoppingItemSurvivesARoundTrip() {
|
||||
let item = ShoppingItem(weekStartDate: Date().startOfWeek(), title: "Leche", isChecked: true, sortOrder: 4)
|
||||
context.insert(item)
|
||||
|
||||
let copy = ShoppingItem(id: item.id, weekStartDate: item.weekStartDate, title: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: item), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.title, "Leche")
|
||||
XCTAssertTrue(copy.isChecked)
|
||||
XCTAssertEqual(copy.sortOrder, 4)
|
||||
}
|
||||
|
||||
// MARK: - Invite codes
|
||||
|
||||
func testInviteCodesUseAnUnambiguousAlphabet() {
|
||||
for _ in 0..<200 {
|
||||
let code = HouseholdService.makeInviteCode()
|
||||
XCTAssertEqual(code.count, 6)
|
||||
// No 0/O/1/I and no vowels: codes get read out loud and retyped.
|
||||
XCTAssertFalse(code.contains(where: { "01IOAEU".contains($0) }), "ambiguous character in \(code)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCodeNormalizationAcceptsHowPeopleTypeIt() {
|
||||
let code = HouseholdService.makeInviteCode()
|
||||
let messy = code.lowercased().split(separator: "").joined()
|
||||
let spaced = code.map(String.init).joined(separator: " ")
|
||||
let dashed = code.prefix(3) + "-" + code.suffix(3)
|
||||
|
||||
XCTAssertEqual(HouseholdService.normalize(messy), code)
|
||||
XCTAssertEqual(HouseholdService.normalize(spaced), code)
|
||||
XCTAssertEqual(HouseholdService.normalize(String(dashed)), code)
|
||||
XCTAssertTrue(HouseholdService.isPlausibleCode(spaced))
|
||||
}
|
||||
|
||||
func testShortOrEmptyCodesAreRejectedBeforeHittingTheNetwork() {
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode(""))
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode("BCD"))
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode("AEIOU"), "vowels aren't part of the alphabet")
|
||||
}
|
||||
|
||||
// MARK: - Shadow
|
||||
|
||||
func testShadowTracksWhatWasSynced() {
|
||||
var shadow = SyncShadow()
|
||||
shadow.set(path: "dishes/abc", fingerprint: "1234")
|
||||
|
||||
XCTAssertEqual(shadow.fingerprint(for: "dishes/abc"), "1234")
|
||||
XCTAssertNil(shadow.fingerprint(for: "dishes/other"))
|
||||
|
||||
shadow.remove(path: "dishes/abc")
|
||||
XCTAssertNil(shadow.fingerprint(for: "dishes/abc"))
|
||||
|
||||
shadow.set(path: "tags/x", fingerprint: "ffff")
|
||||
shadow.reset()
|
||||
XCTAssertTrue(shadow.paths.isEmpty)
|
||||
}
|
||||
|
||||
func testShadowSurvivesARelaunch() {
|
||||
let householdId = "test-\(UUID().uuidString)"
|
||||
var shadow = SyncShadow()
|
||||
shadow.set(path: "dishes/abc", fingerprint: "1234")
|
||||
shadow.save(householdId: householdId)
|
||||
|
||||
var restored = SyncShadow()
|
||||
restored.load(householdId: householdId)
|
||||
|
||||
XCTAssertEqual(restored.fingerprint(for: "dishes/abc"), "1234",
|
||||
"without this the whole store would re-upload on every launch")
|
||||
UserDefaults.standard.removeObject(forKey: "household_shadow_\(householdId)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user