mana-swift-event-sync/Tests/ManaEventSyncTests/StorageTests.swift
Till JS b81ffb4c0b fix: lokal emittierte Events sortieren nicht mehr vor den gesyncten
Derselbe Fehler wie in @mana/event-sync (dort behoben in 0.15.1), hier in
Swift: `sequenceNumber` ist `Int64?`, und SwiftData sortiert über SQLite —
dort ist NULL bei aufsteigender Ordnung der KLEINSTE Wert. Frisch emittierte
Events (noch ohne Nummer vom Server) landeten damit ganz vorn, und der
Replay wandte das Erzeugungs-Event zuletzt an: es überschrieb jede spätere
Änderung.

Sichtbar in pageta: aus dem Share-Sheet gespeicherte Artikel blieben
dauerhaft auf ihrem Platzhalter stehen (Host als Titel, leerer Text),
obwohl `enrichPendingArticles()` sauber ArticleEnriched nachgeschickt hatte.
In einer echten Datenbank 16 von 22 winfuture-Speicherungen — und winfuture
hat gar keine Cookie-Wand, die Extraktion wäre also durchgelaufen.

„NULL zuletzt" lässt sich in einem SortDescriptor nicht ausdrücken, deshalb
sortiert eventsForAggregate/-Prefix jetzt im Speicher (pro Aggregat wenige
Events). Vier Tests decken den gemischten Stream ab; mit dem alten
Verhalten fallen drei davon um (verifiziert).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:52:37 +02:00

202 lines
8.4 KiB
Swift

import XCTest
@testable import ManaEventSync
@MainActor
final class StorageTests: XCTestCase {
private func makeStorage() throws -> EventStorage {
try EventStorage(storeName: "test-\(UUID().uuidString)", appGroupIdentifier: nil, inMemory: true)
}
private func envelope(
aggregate: String,
type: String,
user: String,
eventId: String = ULID.generate(),
occurredAt: String = ISO8601DateFormatter().string(from: .init()),
sequenceNumber: Int64? = nil
) -> EventEnvelope {
EventEnvelope(
eventId: eventId,
aggregateId: aggregate,
appId: "test",
eventType: type,
eventVersion: 1,
occurredAt: occurredAt,
sequenceNumber: sequenceNumber,
actor: .user(principalId: user),
attributedToUserId: user,
origin: "user",
idempotencyKey: eventId,
payload: .object(["v": .int(1)])
)
}
// MARK: - Reihenfolge lokal emittierter Events
/// Der Fall, der in pageta aus dem Share-Sheet gespeicherte Artikel
/// dauerhaft auf ihrem Platzhalter stehen liess: ein Aggregat, das schon
/// gesynct wurde (Erzeugungs-Event mit Nummer), bekommt eine Anreicherung.
/// Die hat noch keine Nummer. Sortiert sie davor, wendet der Replay das
/// Erzeugungs-Event zuletzt an — und die Anreicherung ist weg.
func testUnsequencedEventSortsAfterSyncedOne() throws {
let storage = try makeStorage()
try storage.appendEvents([
envelope(
aggregate: "article:A", type: "ArticleSaved", user: "u1",
eventId: "evt-saved", occurredAt: "2026-05-20T10:00:00Z", sequenceNumber: 1
),
// eventId absichtlich lexikografisch kleiner, occurredAt später:
// die Reihenfolge darf sich auf keins von beidem verlassen.
envelope(
aggregate: "article:A", type: "ArticleEnriched", user: "u1",
eventId: "aaa-enriched", occurredAt: "2026-07-27T10:00:00Z", sequenceNumber: nil
)
])
let events = try storage.eventsForAggregate("article:A")
XCTAssertEqual(events.map(\.eventType), ["ArticleSaved", "ArticleEnriched"])
}
func testUnsequencedEventsKeepChronologicalOrderAmongThemselves() throws {
let storage = try makeStorage()
try storage.appendEvents([
envelope(
aggregate: "article:B", type: "ArticleSaved", user: "u1",
eventId: "evt-0", occurredAt: "2026-05-20T10:00:00Z", sequenceNumber: 1
),
envelope(
aggregate: "article:B", type: "ArticleEnriched", user: "u1",
eventId: "evt-2", occurredAt: "2026-07-27T12:00:00Z"
),
envelope(
aggregate: "article:B", type: "ArticleStatusChanged", user: "u1",
eventId: "evt-1", occurredAt: "2026-07-27T11:00:00Z"
)
])
let events = try storage.eventsForAggregate("article:B")
XCTAssertEqual(
events.map(\.eventType),
["ArticleSaved", "ArticleStatusChanged", "ArticleEnriched"]
)
}
func testSequencedEventsStillSortByNumber() throws {
let storage = try makeStorage()
try storage.appendEvents([
envelope(aggregate: "article:C", type: "Third", user: "u1", eventId: "c", sequenceNumber: 30),
envelope(aggregate: "article:C", type: "First", user: "u1", eventId: "a", sequenceNumber: 10),
envelope(aggregate: "article:C", type: "Second", user: "u1", eventId: "b", sequenceNumber: 20)
])
let events = try storage.eventsForAggregate("article:C")
XCTAssertEqual(events.map(\.eventType), ["First", "Second", "Third"])
}
func testPrefixFetchUsesSameOrdering() throws {
let storage = try makeStorage()
try storage.appendEvents([
envelope(
aggregate: "article:D", type: "ArticleSaved", user: "u1",
eventId: "evt-saved", occurredAt: "2026-05-20T10:00:00Z", sequenceNumber: 7
),
envelope(
aggregate: "article:D", type: "ArticleEnriched", user: "u1",
eventId: "aaa-enriched", occurredAt: "2026-07-27T10:00:00Z"
)
])
let grouped = try storage.eventsForAggregatePrefix("article")
XCTAssertEqual(
grouped["article:D"]?.map(\.eventType),
["ArticleSaved", "ArticleEnriched"]
)
}
func testAppendAndReadByAggregate() throws {
let storage = try makeStorage()
try storage.appendEvents([
envelope(aggregate: "h:1", type: "Created", user: "u1"),
envelope(aggregate: "h:1", type: "NoteSet", user: "u1"),
envelope(aggregate: "h:2", type: "Created", user: "u1")
])
XCTAssertEqual(try storage.eventsForAggregate("h:1").count, 2)
XCTAssertEqual(try storage.eventsForAggregate("h:2").count, 1)
XCTAssertEqual(try storage.countEvents(), 3)
}
func testAppendIsIdempotent() throws {
let storage = try makeStorage()
let event = envelope(aggregate: "h:1", type: "Created", user: "u1")
XCTAssertEqual(try storage.appendEvents([event]), 1)
XCTAssertEqual(try storage.appendEvents([event]), 0)
XCTAssertEqual(try storage.countEvents(), 1)
}
func testOutboxEnqueueDedupAndRemove() throws {
let storage = try makeStorage()
let event = envelope(aggregate: "h:1", type: "Created", user: "u1")
try storage.enqueueOutbox(event)
try storage.enqueueOutbox(event) // dedup
XCTAssertEqual(try storage.outboxCount(), 1)
try storage.removeOutbox(eventIds: [event.eventId])
XCTAssertEqual(try storage.outboxCount(), 0)
}
func testReattributeClaimRewritesLogAndOutbox() throws {
let storage = try makeStorage()
let anon = "anon:guest-1"
let eventA = envelope(aggregate: "h:1", type: "Created", user: anon)
let eventB = envelope(aggregate: "h:2", type: "Created", user: anon)
try storage.appendEvents([eventA, eventB])
try storage.enqueueOutbox(eventA)
try storage.enqueueOutbox(eventB)
let touched = try storage.reattribute(from: anon, to: "user_42")
XCTAssertEqual(touched, 4) // 2 Event-Log + 2 Outbox
let logged = try storage.eventsForAggregate("h:1")
XCTAssertEqual(logged.first?.attributedToUserId, "user_42")
let pending = try storage.pendingOutbox()
XCTAssertTrue(try pending.allSatisfy { try $0.envelope().attributedToUserId == "user_42" })
// Idempotent: zweiter Lauf trifft nichts mehr.
XCTAssertEqual(try storage.reattribute(from: anon, to: "user_42"), 0)
}
func testHasEventsAttributedTo() throws {
let storage = try makeStorage()
XCTAssertFalse(try storage.hasEvents(attributedTo: "anon:g"))
try storage.appendEvents([envelope(aggregate: "h:1", type: "Created", user: "anon:g")])
XCTAssertTrue(try storage.hasEvents(attributedTo: "anon:g"))
XCTAssertFalse(try storage.hasEvents(attributedTo: "user_42"))
// Nach Claim hängt nichts mehr am Anon-Tag → kein Claim-Bedarf mehr.
_ = try storage.reattribute(from: "anon:g", to: "user_42")
XCTAssertFalse(try storage.hasEvents(attributedTo: "anon:g"))
XCTAssertTrue(try storage.hasEvents(attributedTo: "user_42"))
}
func testPruneAnonymousByCount() throws {
let storage = try makeStorage()
for index in 0 ..< 10 {
let occurred = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(index)))
try storage.appendEvents([envelope(
aggregate: "h:\(index)", type: "Created", user: "anon:g", occurredAt: occurred
)])
}
// Ein signiertes Event darf NICHT gepruned werden.
try storage.appendEvents([envelope(aggregate: "s:1", type: "Created", user: "user_42")])
try storage.pruneAnonymous(maxEvents: 3, maxAgeDays: 0)
XCTAssertEqual(try storage.countEvents(), 4) // 3 anon (neueste) + 1 signiert
}
func testMetaRoundTrip() throws {
let storage = try makeStorage()
XCTAssertNil(try storage.metaGet("k"))
try storage.metaSet("k", "v1")
XCTAssertEqual(try storage.metaGet("k"), "v1")
try storage.metaSet("k", "v2")
XCTAssertEqual(try storage.metaGet("k"), "v2")
}
}