pageta-native/Tests/UnitTests/PagetaNativeTests.swift
Till JS 27ef644892 feat(share): ShareExtension erfasst gerendertes HTML gegen Consent-Wände (C1)
Beim Teilen aus Safari greift ein JS-Preprocessing-File (GetPageHTML.js)
das gerenderte outerHTML der bereits zustimmenden Seite und legt es im
neuen SharedHTMLStore (App-Group, keyed auf SHA-256(url)) ab. Die App
schickt dieses HTML bei der Extraktion an /extract/preview mit — das
umgeht Cookie-/Pur-Wände, die der anonyme Server-Fetch sonst extrahiert
(gleicher Bypass wie das Web-Bookmarklet).

- SharedHTMLStore: put/peek/remove/prune, 4,9M-Char-Cap (API-Limit),
  7-Tage-TTL. HTML wird erst nach erfolgreicher Extraktion verworfen,
  damit ein 401/502-Retry (enrich-Sweep) es erneut nutzen kann.
- ShareViewController (iOS + macOS): liest das Property-List-Result,
  legt HTML ab, enqueued die URL wie bisher.
- project.yml: NSExtensionJavaScriptPreprocessingFile + WebPage-Activation
  + JS-Resource + SharedHTMLStore.swift in beide Extension-Targets.
- PagetaAPI.extractPreview(url:html:) + enrichArticle konsultiert den Store.

Greift nur beim Teilen aus Safari (Web-Page-Kontext); nackte URL-Shares
fallen sauber auf den URL-Pfad zurück.

iOS+macOS BUILD SUCCEEDED, 41/41 Tests grün (3 neue für SharedHTMLStore).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:01:21 +02:00

379 lines
12 KiB
Swift

import ManaCore
import XCTest
@testable import PagetaNative
final class PagetaNativeTests: XCTestCase {
// MARK: - AppConfig
func testAppConfig_apiPointsToPagetaDotCom() {
XCTAssertEqual(AppConfig.apiBaseURL.absoluteString, "https://api.pageta.com")
}
func testAppConfig_authPointsToManaAuth() {
XCTAssertEqual(AppConfig.manaAppConfig.authBaseURL.absoluteString, "https://auth.mana.how")
}
func testAppConfig_keychainService_usesSharedManaGroup() {
// Cross-App-SSO: alle mana-e.V.-Apps schreiben Tokens unter der
// gleichen Keychain-Service-Bezeichnung. Login in einer App
// macht alle anderen sofort eingeloggt (sofern Keychain-Sharing-
// Entitlement matched).
XCTAssertEqual(AppConfig.manaAppConfig.keychainService, ManaSharedKeychainGroup)
}
func testAppConfig_keychainAccessGroup_usesSharedManaGroup() {
XCTAssertEqual(AppConfig.manaAppConfig.keychainAccessGroup, ManaSharedKeychainGroup)
}
func testAppConfig_appGroup_isPagetaSpecific() {
// App-Group bleibt pageta-eigen (für Widget/ShareExt-Daten-
// Sharing), nur die Keychain ist cross-app.
XCTAssertEqual(AppConfig.manaAppConfig.appGroup, "group.ev.mana.pageta")
}
// MARK: - Topic
func testTopic_allCases_haveLabelsAndEmoji() {
for topic in Topic.allCases {
XCTAssertFalse(topic.label.isEmpty, "Topic \(topic.rawValue) has empty label")
XCTAssertFalse(topic.emoji.isEmpty, "Topic \(topic.rawValue) has empty emoji")
}
}
func testTopic_matchesWireFormat() throws {
// Mirror der ALL_TOPICS-Liste aus shared-rss/types.ts. Driftet
// die Liste auseinander, brechen Pool-Responses.
let expected: Set<String> = [
"tech", "wissenschaft", "weltgeschehen", "wirtschaft",
"kultur", "gesundheit", "politik",
]
let actual = Set(Topic.allCases.map(\.rawValue))
XCTAssertEqual(actual, expected)
}
// MARK: - DTO-Decoding
func testFeedItem_decodesFromWireFormat() throws {
let json = """
{
"id": "abc-123",
"originalUrl": "https://example.com/a",
"title": "Beispiel-Artikel",
"excerpt": "Kurzer Auszug",
"content": "Voller Text",
"author": "Autor X",
"siteName": "Example",
"sourceSlug": "example",
"imageUrl": null,
"topic": "tech",
"language": "de",
"wordCount": 500,
"readingTimeMinutes": 3,
"publishedAt": "2026-05-18T10:00:00Z",
"ingestedAt": "2026-05-18T11:00:00Z",
"score": 1.23
}
""".data(using: .utf8)!
let item = try JSONDecoder().decode(FeedItem.self, from: json)
XCTAssertEqual(item.id, "abc-123")
XCTAssertEqual(item.topic, .tech)
XCTAssertEqual(item.score, 1.23)
XCTAssertNil(item.imageUrl)
}
func testArticle_decodesFromWireFormat() throws {
let json = """
{
"id": "art-1",
"userId": "user-42",
"originalUrl": "https://example.com/a",
"title": "Save-it-later-Item",
"excerpt": null,
"content": "...",
"htmlContent": null,
"author": null,
"siteName": "Example",
"imageUrl": null,
"wordCount": null,
"readingTimeMinutes": null,
"publishedAt": null,
"status": "unread",
"readingProgress": 0,
"isFavorite": false,
"savedAt": "2026-05-18T11:00:00Z",
"readAt": null,
"userNote": null,
"tags": [],
"extractedVersion": 1,
"createdAt": "2026-05-18T11:00:00Z",
"updatedAt": "2026-05-18T11:00:00Z"
}
""".data(using: .utf8)!
let article = try JSONDecoder().decode(Article.self, from: json)
XCTAssertEqual(article.id, "art-1")
XCTAssertEqual(article.status, .unread)
XCTAssertEqual(article.readingProgress, 0)
XCTAssertFalse(article.isFavorite)
}
func testArticleStatus_allCases_haveLabels() {
for status in ArticleStatus.allCases {
XCTAssertFalse(status.label.isEmpty)
}
}
// MARK: - URL-Routing
func testRoute_parsesPagetaDotComArticle() {
let url = URL(string: "https://pageta.com/article/abc-123")!
XCTAssertEqual(PagetaRoute.parse(url), .article(id: "abc-123"))
}
func testRoute_parsesPagetaSchemeArticle() {
let url = URL(string: "pageta://article/abc-123")!
XCTAssertEqual(PagetaRoute.parse(url), .article(id: "abc-123"))
}
func testRoute_caseInsensitiveHost() {
let url = URL(string: "HTTPS://PAGETA.COM/article/abc-123")!
XCTAssertEqual(PagetaRoute.parse(url), .article(id: "abc-123"))
}
func testRoute_rejectsOldManaHowDomain() {
// pageta.com ist Lead-Domain seit 2026-05-18 — die mana.how-
// Subdomain ist bewusst nicht im AASA für Native.
let url = URL(string: "https://pageta.mana.how/article/abc")!
XCTAssertNil(PagetaRoute.parse(url))
}
func testRoute_rejectsForeignHost() {
let url = URL(string: "https://example.com/article/abc")!
XCTAssertNil(PagetaRoute.parse(url))
}
func testRoute_rejectsEmptyArticleId() {
let url = URL(string: "https://pageta.com/article/")!
XCTAssertNil(PagetaRoute.parse(url))
}
func testRoute_rejectsUnknownPath() {
let url = URL(string: "https://pageta.com/about")!
XCTAssertNil(PagetaRoute.parse(url))
}
// MARK: - PendingShareStore (App-Group-Queue)
func testShareStore_enqueueAndDrain() {
let store = makeIsolatedStore()
XCTAssertTrue(store.currentQueue().isEmpty)
let url = URL(string: "https://example.com/a")!
XCTAssertTrue(store.enqueue(url))
XCTAssertEqual(store.currentQueue().map(\.absoluteString), [url.absoluteString])
}
func testShareStore_dedupesDuplicateEnqueue() {
let store = makeIsolatedStore()
let url = URL(string: "https://example.com/a")!
XCTAssertTrue(store.enqueue(url))
XCTAssertFalse(store.enqueue(url))
XCTAssertEqual(store.currentQueue().count, 1)
}
func testShareStore_removeAfterFlush() {
let store = makeIsolatedStore()
let a = URL(string: "https://example.com/a")!
let b = URL(string: "https://example.com/b")!
store.enqueue(a)
store.enqueue(b)
store.remove(a)
XCTAssertEqual(store.currentQueue().map(\.absoluteString), [b.absoluteString])
}
// MARK: - SharedHTMLStore (App-Group HTML-Cache für Consent-Wall-Bypass)
func testHTMLStore_roundTripKeyedByURL() {
let store = makeIsolatedHTMLStore()
let a = URL(string: "https://example.com/a")!
let b = URL(string: "https://example.com/b")!
XCTAssertNil(store.peek(for: a))
store.put("<html>A</html>", for: a)
XCTAssertEqual(store.peek(for: a), "<html>A</html>")
XCTAssertNil(store.peek(for: b), "Keys dürfen sich nicht überschneiden")
store.remove(for: a)
XCTAssertNil(store.peek(for: a), "remove muss den Blob löschen")
}
func testHTMLStore_dropsOversizeHTML() {
let store = makeIsolatedHTMLStore()
let url = URL(string: "https://example.com/big")!
let huge = String(repeating: "x", count: SharedHTMLStore.maxHTMLChars + 1)
store.put(huge, for: url)
XCTAssertNil(store.peek(for: url), "Zu großes HTML wird verworfen (API-Cap)")
}
func testHTMLStore_pruneRemovesStaleBlobs() {
let store = makeIsolatedHTMLStore(maxAgeDays: 0)
let url = URL(string: "https://example.com/old")!
store.put("<html></html>", for: url)
store.prune()
XCTAssertNil(store.peek(for: url), "maxAge 0 → alles gilt als verwaist")
}
private func makeIsolatedHTMLStore(maxAgeDays: Double = 7) -> SharedHTMLStore {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("pageta-html-tests-\(UUID().uuidString)", isDirectory: true)
return SharedHTMLStore(directory: dir, maxAgeDays: maxAgeDays)
}
private func makeIsolatedStore() -> PendingShareStore {
// Test-isolierter Slot — kollidiert nicht mit dem prod-Key.
let key = "pageta.tests.shares.\(UUID().uuidString)"
let store = PendingShareStore(appGroup: "test.\(UUID().uuidString)", key: key)
store.clear()
return store
}
// MARK: - SpotlightIndex
#if canImport(CoreSpotlight)
func testSpotlight_searchableItem_carriesArticleFields() {
let article = Article(
id: "abc-123",
userId: "u",
originalUrl: "https://x/a",
title: "Beispiel-Artikel",
excerpt: "Kurzer Auszug",
content: "Body",
htmlContent: nil,
author: "Autor X",
siteName: "Example",
imageUrl: nil,
wordCount: nil,
readingTimeMinutes: nil,
publishedAt: nil,
status: .unread,
readingProgress: 0,
isFavorite: false,
savedAt: "2026-05-18T11:00:00Z",
readAt: nil,
userNote: nil,
extractedVersion: 1,
createdAt: "2026-05-18T11:00:00Z",
updatedAt: "2026-05-18T11:00:00Z"
)
let item = SpotlightIndex.makeSearchableItem(article)
XCTAssertEqual(item.uniqueIdentifier, "abc-123")
XCTAssertEqual(item.domainIdentifier, SpotlightIndex.domainIdentifier)
XCTAssertEqual(item.attributeSet.title, "Beispiel-Artikel")
XCTAssertEqual(item.attributeSet.contentDescription, "Kurzer Auszug")
XCTAssertEqual(item.attributeSet.contentURL?.absoluteString, "pageta://article/abc-123")
XCTAssertTrue(item.attributeSet.keywords?.contains("Example") == true)
XCTAssertTrue(item.attributeSet.keywords?.contains("Autor X") == true)
XCTAssertTrue(item.attributeSet.keywords?.contains("Ungelesen") == true)
}
#endif
// MARK: - SidebarSection (RootView)
func testSidebarSection_allCases_haveLabelAndIcon() {
for section in SidebarSection.allCases {
XCTAssertFalse(section.label.isEmpty)
XCTAssertFalse(section.icon.isEmpty)
}
}
func testSidebarSection_rawValue_roundTrip() {
// SceneStorage persistiert über rawValue — Drift bei Enum-
// Umbenennen würde stale Selections zurückwerfen.
for section in SidebarSection.allCases {
XCTAssertEqual(SidebarSection(rawValue: section.rawValue), section)
}
}
// MARK: - WidgetSnapshot
func testWidgetSnapshot_decodesFromWireFormat() throws {
let json = """
{
"feed": [
{
"id": "feed-1",
"title": "Sample",
"siteName": "Example",
"topic": "tech",
"imageUrl": null,
"publishedAt": null,
"url": "https://example.com/x"
}
],
"saved": { "unread": 4, "reading": 1, "finished": 12, "archived": 38 },
"lastSyncAt": "2026-05-18T16:00:00Z"
}
""".data(using: .utf8)!
let snap = try JSONDecoder().decode(WidgetSnapshot.self, from: json)
XCTAssertEqual(snap.feed.count, 1)
XCTAssertEqual(snap.feed.first?.id, "feed-1")
XCTAssertEqual(snap.saved.unread, 4)
XCTAssertEqual(snap.saved.archived, 38)
}
func testWidgetSnapshotStore_roundTrip() throws {
let key = "pageta.tests.widget.\(UUID().uuidString)"
let store = WidgetSnapshotStore(appGroup: "test.\(UUID().uuidString)", key: key)
store.clear()
XCTAssertNil(store.load())
let snap = WidgetSnapshot(
feed: [WidgetFeedItem(id: "a", title: "T", siteName: "S", topic: "tech", imageUrl: nil, publishedAt: nil, url: "https://x")],
saved: WidgetSnapshot.SavedCounts(unread: 1, reading: 0, finished: 2, archived: 3),
lastSyncAt: "2026-05-18T16:00:00Z"
)
try store.save(snap)
XCTAssertEqual(store.load(), snap)
store.clear()
XCTAssertNil(store.load())
}
// MARK: - Highlight
func testHighlightColor_allCases_haveLabelsAndColor() {
for color in HighlightColor.allCases {
XCTAssertFalse(color.label.isEmpty, "Color \(color.rawValue) has empty label")
}
}
func testHighlightColor_matchesWireFormat() {
let expected: Set<String> = ["yellow", "green", "blue", "pink"]
let actual = Set(HighlightColor.allCases.map(\.rawValue))
XCTAssertEqual(actual, expected)
}
func testHighlight_decodesFromWireFormat() throws {
let json = """
{
"id": "hl-1",
"articleId": "art-1",
"userId": "user-42",
"text": "Eine markierte Passage.",
"note": null,
"color": "yellow",
"startOffset": 0,
"endOffset": 23,
"contextBefore": null,
"contextAfter": null,
"createdAt": "2026-05-18T11:00:00Z",
"updatedAt": "2026-05-18T11:00:00Z"
}
""".data(using: .utf8)!
let highlight = try JSONDecoder().decode(Highlight.self, from: json)
XCTAssertEqual(highlight.id, "hl-1")
XCTAssertEqual(highlight.color, .yellow)
XCTAssertEqual(highlight.startOffset, 0)
XCTAssertEqual(highlight.endOffset, 23)
XCTAssertNil(highlight.note)
}
}