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>
202 lines
8.4 KiB
Swift
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")
|
|
}
|
|
}
|