pageta-native/Tests/UnitTests/EventSyncReducerTests.swift
Till JS ca4b17b1a3 feat: ArticleMediaArchived-Mirror + archivierte Vorschaubilder (Liste + Reader-Header)
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>
2026-07-14 18:52:17 +02:00

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")
}
}