Spiegel von @mana/shared-schemas/pageta@0.1.21 (Web = SOT): - ArticleMediaArchivedPayload + Reducer-Case (non-destruktiv, if-let wie warning); ArticleEnriched clobbert imageUrl nicht mehr - ArticleState.htmlContent; stateToArticle mappt es (war nil) - ArticleExtractPreview dekodiert imageUrl (og:image-Hotlink) - archiveMedia(): POST /articles/archive-media, fire-and-forget nach Save/Enrich; NUR die archivierte media.mana.how-URL wird emittiert, nie der Hotlink; Delete-Race-Guard wie im Web-Client - MediaURL.variant (thumb/medium/large, Spiegel von media-url.ts) - Lese-Liste: 56pt-Thumbnail; Reader-Header: Lead-Image (medium, 200pt) - 5 neue Reducer-/MediaURL-Tests (54/54 grün, iPhone-17-Simulator) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
316 lines
10 KiB
Swift
316 lines
10 KiB
Swift
import ManaEventSync
|
|
@testable import PagetaNative
|
|
import XCTest
|
|
|
|
/// Reducer-Tests für die π-11 Event-Sync-Migration. Sicherstellt, dass
|
|
/// Replay über Events den erwarteten State produziert. Pendant-Tests
|
|
/// für die Web-Seite leben in `apps/web/src/lib/event-sync-api.test.ts`
|
|
/// (sofern dort vorhanden — sonst hier die führende Doku).
|
|
final class EventSyncReducerTests: XCTestCase {
|
|
// MARK: - Helpers
|
|
|
|
private func envelope(
|
|
_ type: String,
|
|
aggregateId: String = "article:01ABC",
|
|
userId: String = "user-1",
|
|
occurredAt: String = "2026-05-20T10:00:00Z",
|
|
payload: some Encodable
|
|
) throws -> EventEnvelope {
|
|
try EventEnvelope(
|
|
eventId: "evt-\(type)",
|
|
aggregateId: aggregateId,
|
|
appId: "pageta",
|
|
eventType: type,
|
|
eventVersion: 1,
|
|
occurredAt: occurredAt,
|
|
receivedAt: nil,
|
|
sequenceNumber: nil,
|
|
causationId: nil,
|
|
correlationId: nil,
|
|
actor: .user(principalId: userId),
|
|
attributedToUserId: userId,
|
|
origin: "user",
|
|
idempotencyKey: "idem-\(type)-\(aggregateId)",
|
|
payload: JSONValue.from(payload)
|
|
)
|
|
}
|
|
|
|
private func sampleSavedPayload(id: String = "01ABC") -> ArticleSavedPayload {
|
|
ArticleSavedPayload(
|
|
articleId: id,
|
|
originalUrl: "https://example.com/foo",
|
|
urlHash: "abc123",
|
|
title: "Foo",
|
|
excerpt: "kurz",
|
|
content: "Inhalt",
|
|
author: "Autor",
|
|
siteName: "Example",
|
|
imageUrl: nil,
|
|
wordCount: 100,
|
|
readingTimeMinutes: 1,
|
|
publishedAt: nil,
|
|
userNote: nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Article reducer
|
|
|
|
func testReplayArticle_emptyEvents_returnsNil() {
|
|
XCTAssertNil(Reducers.replayArticle([]))
|
|
}
|
|
|
|
func testReplayArticle_savedOnly_projectsInitialState() throws {
|
|
let e = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let state = Reducers.replayArticle([e])
|
|
XCTAssertEqual(state?.id, "01ABC")
|
|
XCTAssertEqual(state?.title, "Foo")
|
|
XCTAssertEqual(state?.status, .unread)
|
|
XCTAssertEqual(state?.readingProgress, 0)
|
|
XCTAssertFalse(state?.isFavorite ?? true)
|
|
XCTAssertNil(state?.deletedAt)
|
|
XCTAssertEqual(state?.savedAt, "2026-05-20T10:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_statusChanged_setsStatusAndReadAt() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let changed = try envelope(
|
|
EventType.articleStatusChanged,
|
|
occurredAt: "2026-05-20T11:00:00Z",
|
|
payload: ArticleStatusChangedPayload(articleId: "01ABC", newStatus: .finished)
|
|
)
|
|
let state = Reducers.replayArticle([saved, changed])
|
|
XCTAssertEqual(state?.status, .finished)
|
|
XCTAssertEqual(state?.readAt, "2026-05-20T11:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_favoriteAndProgress() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let fav = try envelope(
|
|
EventType.articleFavoriteToggled,
|
|
payload: ArticleFavoriteToggledPayload(articleId: "01ABC", isFavorite: true)
|
|
)
|
|
let progress = try envelope(
|
|
EventType.articleProgressUpdated,
|
|
payload: ArticleProgressUpdatedPayload(articleId: "01ABC", newProgress: 0.42)
|
|
)
|
|
let state = Reducers.replayArticle([saved, fav, progress])
|
|
XCTAssertTrue(state?.isFavorite ?? false)
|
|
XCTAssertEqual(state?.readingProgress, 0.42)
|
|
}
|
|
|
|
func testReplayArticle_deletedSetsTombstone() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let deleted = try envelope(
|
|
EventType.articleDeleted,
|
|
occurredAt: "2026-05-20T12:00:00Z",
|
|
payload: ArticleDeletedPayload(articleId: "01ABC")
|
|
)
|
|
let state = Reducers.replayArticle([saved, deleted])
|
|
XCTAssertEqual(state?.deletedAt, "2026-05-20T12:00:00Z")
|
|
}
|
|
|
|
func testReplayArticle_unknownEventTypeIsSkipped() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
struct Dummy: Codable { let foo: String }
|
|
let unknown = try envelope("SomeFutureEvent", payload: Dummy(foo: "bar"))
|
|
let state = Reducers.replayArticle([saved, unknown])
|
|
// Saved-State unverändert.
|
|
XCTAssertEqual(state?.title, "Foo")
|
|
XCTAssertEqual(state?.status, .unread)
|
|
}
|
|
|
|
func testReplayArticle_tagsSetReplacesArray() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let tags1 = try envelope(
|
|
EventType.articleTagsSet,
|
|
payload: ArticleTagsSetPayload(articleId: "01ABC", tags: ["a", "b"])
|
|
)
|
|
let tags2 = try envelope(
|
|
EventType.articleTagsSet,
|
|
payload: ArticleTagsSetPayload(articleId: "01ABC", tags: ["a", "b", "c"])
|
|
)
|
|
let state = Reducers.replayArticle([saved, tags1, tags2])
|
|
XCTAssertEqual(state?.tags, ["a", "b", "c"])
|
|
}
|
|
|
|
func testReplayArticle_mediaArchivedSetsImageUrlAndHtmlContent() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let archived = try envelope(
|
|
EventType.articleMediaArchived,
|
|
payload: ArticleMediaArchivedPayload(
|
|
articleId: "01ABC",
|
|
htmlContent: "<p>archiviert</p>",
|
|
imageUrl: "https://media.mana.how/api/v1/media/M1/file",
|
|
mediaIds: ["M1"]
|
|
)
|
|
)
|
|
let state = Reducers.replayArticle([saved, archived])
|
|
XCTAssertEqual(state?.imageUrl, "https://media.mana.how/api/v1/media/M1/file")
|
|
XCTAssertEqual(state?.htmlContent, "<p>archiviert</p>")
|
|
// Rest unberührt (non-destruktiv).
|
|
XCTAssertEqual(state?.title, "Foo")
|
|
XCTAssertEqual(state?.content, "Inhalt")
|
|
}
|
|
|
|
func testReplayArticle_mediaArchivedImageOnly_keepsExistingHtmlContent() throws {
|
|
// imageUrl-only-Archiv (Native) darf ein früheres Voll-Archiv
|
|
// (Web mit htmlContent) nicht leeren — `?? state`-Semantik.
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let fullArchive = try envelope(
|
|
EventType.articleMediaArchived,
|
|
payload: ArticleMediaArchivedPayload(
|
|
articleId: "01ABC",
|
|
htmlContent: "<p>voll</p>",
|
|
imageUrl: "https://media.mana.how/api/v1/media/M1/file",
|
|
mediaIds: ["M1"]
|
|
)
|
|
)
|
|
let imageOnly = try envelope(
|
|
EventType.articleMediaArchived,
|
|
payload: ArticleMediaArchivedPayload(
|
|
articleId: "01ABC",
|
|
htmlContent: nil,
|
|
imageUrl: "https://media.mana.how/api/v1/media/M2/file",
|
|
mediaIds: ["M2"]
|
|
)
|
|
)
|
|
let state = Reducers.replayArticle([saved, fullArchive, imageOnly])
|
|
XCTAssertEqual(state?.htmlContent, "<p>voll</p>")
|
|
XCTAssertEqual(state?.imageUrl, "https://media.mana.how/api/v1/media/M2/file")
|
|
}
|
|
|
|
func testReplayArticle_enrichedWithNilImageUrl_keepsArchivedImage() throws {
|
|
// Enrich ohne Bild (Native emittiert imageUrl: nil) darf ein
|
|
// bereits archiviertes Bild nicht clobbern.
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let archived = try envelope(
|
|
EventType.articleMediaArchived,
|
|
payload: ArticleMediaArchivedPayload(
|
|
articleId: "01ABC",
|
|
htmlContent: nil,
|
|
imageUrl: "https://media.mana.how/api/v1/media/M1/file",
|
|
mediaIds: ["M1"]
|
|
)
|
|
)
|
|
let enriched = try envelope(
|
|
EventType.articleEnriched,
|
|
payload: ArticleEnrichedPayload(
|
|
articleId: "01ABC",
|
|
title: "Foo (angereichert)",
|
|
excerpt: "kurz",
|
|
content: "Neuer Inhalt",
|
|
author: "Autor",
|
|
siteName: "Example",
|
|
imageUrl: nil,
|
|
wordCount: 120,
|
|
readingTimeMinutes: 1,
|
|
publishedAt: nil,
|
|
warning: nil
|
|
)
|
|
)
|
|
let state = Reducers.replayArticle([saved, archived, enriched])
|
|
XCTAssertEqual(state?.imageUrl, "https://media.mana.how/api/v1/media/M1/file")
|
|
XCTAssertEqual(state?.title, "Foo (angereichert)")
|
|
XCTAssertEqual(state?.content, "Neuer Inhalt")
|
|
}
|
|
|
|
func testReplayArticle_enrichedWithImageUrl_overwrites() throws {
|
|
let saved = try envelope(EventType.articleSaved, payload: sampleSavedPayload())
|
|
let enriched = try envelope(
|
|
EventType.articleEnriched,
|
|
payload: ArticleEnrichedPayload(
|
|
articleId: "01ABC",
|
|
title: "Foo",
|
|
excerpt: nil,
|
|
content: "Inhalt",
|
|
author: nil,
|
|
siteName: nil,
|
|
imageUrl: "https://media.mana.how/api/v1/media/M9/file",
|
|
wordCount: nil,
|
|
readingTimeMinutes: nil,
|
|
publishedAt: nil,
|
|
warning: nil
|
|
)
|
|
)
|
|
let state = Reducers.replayArticle([saved, enriched])
|
|
XCTAssertEqual(state?.imageUrl, "https://media.mana.how/api/v1/media/M9/file")
|
|
}
|
|
|
|
// MARK: - MediaURL variants
|
|
|
|
func testMediaURL_variantOnlyForCanonicalMediaURLs() {
|
|
XCTAssertEqual(
|
|
MediaURL.variant("https://media.mana.how/api/v1/media/abc-123/file", .thumb),
|
|
"https://media.mana.how/api/v1/media/abc-123/file/thumb"
|
|
)
|
|
XCTAssertEqual(
|
|
MediaURL.variant("https://media.mana.how/api/v1/media/abc-123/file", .medium),
|
|
"https://media.mana.how/api/v1/media/abc-123/file/medium"
|
|
)
|
|
// Fremde URLs (Hotlinks aus Prä-Archiv-Zeit) unverändert.
|
|
XCTAssertEqual(
|
|
MediaURL.variant("https://example.com/bild.jpg", .thumb),
|
|
"https://example.com/bild.jpg"
|
|
)
|
|
// Bereits abgeleitete Varianten nicht doppelt suffixen.
|
|
XCTAssertEqual(
|
|
MediaURL.variant("https://media.mana.how/api/v1/media/abc/file/thumb", .thumb),
|
|
"https://media.mana.how/api/v1/media/abc/file/thumb"
|
|
)
|
|
}
|
|
|
|
// MARK: - Highlight reducer
|
|
|
|
func testReplayHighlight_createdAndDeleted() throws {
|
|
let created = try envelope(
|
|
EventType.highlightCreated,
|
|
aggregateId: "highlight:H1",
|
|
payload: HighlightCreatedPayload(
|
|
highlightId: "H1",
|
|
articleId: "01ABC",
|
|
text: "wichtige Stelle",
|
|
note: nil,
|
|
color: .yellow,
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
contextBefore: nil,
|
|
contextAfter: nil
|
|
)
|
|
)
|
|
let s1 = Reducers.replayHighlight([created])
|
|
XCTAssertEqual(s1?.id, "H1")
|
|
XCTAssertEqual(s1?.text, "wichtige Stelle")
|
|
XCTAssertNil(s1?.deletedAt)
|
|
|
|
let deleted = try envelope(
|
|
EventType.highlightDeleted,
|
|
aggregateId: "highlight:H1",
|
|
occurredAt: "2026-05-20T13:00:00Z",
|
|
payload: HighlightDeletedPayload(highlightId: "H1")
|
|
)
|
|
let s2 = Reducers.replayHighlight([created, deleted])
|
|
XCTAssertEqual(s2?.deletedAt, "2026-05-20T13:00:00Z")
|
|
}
|
|
|
|
// MARK: - JSONValue roundtrip
|
|
|
|
func testJSONValue_roundtripStruct() throws {
|
|
let payload = ArticleStatusChangedPayload(articleId: "X", newStatus: .reading)
|
|
let value = try JSONValue.from(payload)
|
|
let decoded: ArticleStatusChangedPayload = try value.decode()
|
|
XCTAssertEqual(decoded.articleId, "X")
|
|
XCTAssertEqual(decoded.newStatus, .reading)
|
|
}
|
|
|
|
// MARK: - ULID
|
|
|
|
func testULID_isSortableByCreationOrder() {
|
|
let a = ULID.generate()
|
|
// minimaler Schlaf damit der Millis-Anteil sich ändert
|
|
Thread.sleep(forTimeInterval: 0.002)
|
|
let b = ULID.generate()
|
|
XCTAssertEqual(a.count, 26)
|
|
XCTAssertEqual(b.count, 26)
|
|
XCTAssertLessThan(a, b, "ULIDs sollen lexikografisch nach Zeit sortieren")
|
|
}
|
|
}
|