diff --git a/.builder-init.md b/.builder-init.md new file mode 100644 index 0000000..95b2134 --- /dev/null +++ b/.builder-init.md @@ -0,0 +1,253 @@ +# Builder Init — single-app-pivot + +## Current File Structure + +``` +ios/ +├── Shared/ +│ ├── Config.swift — App-wide constants (backendBaseURL = "https://piper.workers.dev") +│ ├── CookieManager.swift — Uses AppGroupStorage (UserDefaults suiteName: "group.com.piper.app") +│ └── Models.swift — ConnectionState, ExtractedContent, SaveResponse +├── Piper/ +│ ├── PiperApp.swift — @main, creates CookieManager() and passes to ContentView +│ ├── ContentView.swift — Shows connect/connected states, no "Pipe Article" button yet +│ ├── XLoginView.swift — WKWebView login sheet, detects x.com/home redirect +│ └── Piper.entitlements — Contains com.apple.security.application-groups +├── PiperShareExtension/ +│ ├── ShareViewController.swift — UIKit orchestrator: cookies → extract → POST → clipboard +│ ├── PiperShareExtension.entitlements — App Group entitlement +│ ├── readability.js — Mozilla Readability (real, not stub) +│ └── Services/ +│ ├── ContentExtractor.swift — WKWebView + readability.js extraction +│ └── PiperAPIClient.swift — HTTP POST /save client +├── PiperShareExtensionTests/ +│ ├── ContentExtractorTests.swift — imports @testable PiperShareExtension +│ ├── PiperAPIClientTests.swift — imports @testable PiperShareExtension +│ ├── ShareViewControllerTests.swift — imports @testable PiperShareExtension (DELETE) +│ └── ConfigTests.swift — imports @testable PiperShareExtension +└── PiperTests/ + ├── CookieManagerTests.swift — imports @testable Piper, uses InMemoryStorage mock + ├── ContentViewTests.swift — imports @testable Piper + └── LoginDetectionTests.swift — imports @testable Piper +``` + +Note: No Xcode project file (.xcodeproj) is present in the repo — the project is managed +separately (likely created manually in Xcode). The repo only contains the Swift source files. +This means ALL Xcode project changes (target membership, build phases) must be done via +direct file edits to the source tree. The absence of a .xcodeproj means tests are likely +run via an Xcode project that exists only on developer machines. + +## What Needs to Change + +### Files to Modify + +1. **ios/Shared/CookieManager.swift** + - Remove `AppGroupStorage` class (uses `UserDefaults(suiteName:)` which requires entitlements) + - Replace with `StandardStorage` that wraps `UserDefaults.standard` + - Update `convenience init()` to use `StandardStorage()` instead of `AppGroupStorage()` + - Update comment: "App Group" → "standard UserDefaults" + - The `CookieStorage` protocol and `CookieManager` class remain structurally identical + - CookieManagerTests already uses `InMemoryStorage` mock — no test changes needed for + the storage-switch itself, but a new test verifying StandardStorage uses UserDefaults.standard + is required per spec + +2. **ios/Piper/ContentView.swift** + - Add "Pipe Article" button in the `.connected` case + - Add a `pipelineController` dependency (injected) + - Add state for showing PipeView sheet: `@State private var showingPipeView = false` + - Add state for clipboard URL and error messages + - The connected section currently shows: "You're all set. Use the share sheet to pipe articles." + → Replace with "Pipe Article" button + disconnect button + - Wire to PipeView sheet + +3. **ios/PiperTests/CookieManagerTests.swift** + - Add Test 1: verify that `CookieManager()` (convenience init) uses standard UserDefaults, + not an App Group. This likely tests that keys are stored under `UserDefaults.standard` + +### Files to Move (content copy + import update) + +4. **ios/PiperShareExtension/Services/ContentExtractor.swift → ios/Piper/Services/ContentExtractor.swift** + - Change `@testable import PiperShareExtension` → `@testable import Piper` in tests + - Change error message: "readability.js resource is missing from the extension bundle" → + "readability.js resource is missing from the app bundle" + - `Bundle(for: ContentExtractor.self)` will now resolve to the Piper app bundle + +5. **ios/PiperShareExtension/Services/PiperAPIClient.swift → ios/Piper/Services/PiperAPIClient.swift** + - No content changes needed (uses Config.backendBaseURL from Shared/) + +6. **ios/PiperShareExtension/readability.js → ios/Piper/Resources/readability.js** + - Pure file move, no content changes + +7. **ios/PiperShareExtensionTests/ContentExtractorTests.swift → ios/PiperTests/ContentExtractorTests.swift** + - Change `@testable import PiperShareExtension` → `@testable import Piper` + +8. **ios/PiperShareExtensionTests/PiperAPIClientTests.swift → ios/PiperTests/PiperAPIClientTests.swift** + - Change `@testable import PiperShareExtension` → `@testable import Piper` + +9. **ios/PiperShareExtensionTests/ConfigTests.swift → ios/PiperTests/ConfigTests.swift** + - Change `@testable import PiperShareExtension` → `@testable import Piper` + +### Files to Create + +10. **ios/Piper/Services/PipelineController.swift** + - Protocol-based dependencies: `ContentExtracting`, `PiperAPIClientProtocol`, `CookieManager` + - Method: `pipe(urlString: String, completion: @escaping (Result) -> Void)` + - Error cases: notLoggedIn, invalidURL, extractionFailed(Error), saveFailed(Error) + - Orchestration: check cookies → validate URL → extract → save → return result URL string + - No UI dependencies — pure service class + - Should be async-friendly (completion handlers) + +11. **ios/Piper/PipeView.swift** + - SwiftUI view showing extraction progress/result + - Receives a URL string from clipboard + - Uses `PipelineController` to run the pipe flow + - Shows: loading spinner → success ("Saved — paste into Instapaper") or error + - No direct network or storage access + +12. **ios/PiperTests/PipelineControllerTests.swift** + - Test 1: No cookies → returns notLoggedIn error + - Test 2: Happy path → valid cookies + mock extractor + mock API → returns success URL + - Test 3: Extraction failure → returns extraction error + - Test 4: API failure → extraction succeeds + API fails → returns save error + - Test 5: Invalid URL → returns invalidURL error + +### Files to Delete + +13. **ios/PiperShareExtension/** (entire directory) + - ShareViewController.swift + - PiperShareExtension.entitlements + - readability.js + - Services/ContentExtractor.swift + - Services/PiperAPIClient.swift + +14. **ios/PiperShareExtensionTests/** (entire directory) + - ContentExtractorTests.swift (moved to PiperTests) + - PiperAPIClientTests.swift (moved to PiperTests) + - ShareViewControllerTests.swift (deleted — no replacement, tests covered by PipelineControllerTests) + - ConfigTests.swift (moved to PiperTests) + +15. **ios/Piper/Piper.entitlements** (delete — App Group no longer needed) + +## Key Insights About the Repo + +### No .xcodeproj in Repo +The `ios/` directory does NOT contain a `.xcodeproj` file. The Xcode project is managed +separately. This means: +- We cannot edit target membership via pbxproj +- All changes are to source files only +- The spec says "readability.js is bundled as a resource in the app target" — this is a + Xcode project setting that must be done manually in Xcode. We can only place the file + in the right directory. +- Tests reference `@testable import Piper` and `@testable import PiperShareExtension` — + these target names are defined in the Xcode project + +### CookieStorage Protocol Design +The `CookieStorage` protocol in CookieManager.swift is excellent for testability. The only +change needed is replacing `AppGroupStorage` (which uses `UserDefaults(suiteName:)`) with +a new `StandardStorage` class that uses `UserDefaults.standard`. The `CookieManager` class +itself stays unchanged. + +### ShareViewController Orchestration Logic → PipelineController +`ShareViewController` orchestrates: cookieManager.hasCookies → extractSharedURL → +contentExtractor.extract → apiClient.save → pasteboard.string = url → showSuccess. + +`PipelineController` should mirror this logic but: +- Take a URL string as input (from UIPasteboard in the app) instead of NSExtensionContext +- Be fully testable with injected mocks +- Return results via completion handlers or async/await + +### UIPasteboardProtocol +The `UIPasteboardProtocol` is currently defined in `ShareViewController.swift`. When that +file is deleted, this protocol needs a new home. It could go in: +- `ios/Piper/Services/PipelineController.swift` (since the controller reads clipboard) +- Or a new small file + +Actually, looking more carefully at the spec: PipelineController takes a URL string as +input (the view reads from clipboard and passes it). So the view is responsible for reading +the clipboard, PipelineController just processes the URL. UIPasteboardProtocol may not be +needed in PipelineController at all — the view reads UIPasteboard.general.string and passes +it to PipelineController. + +### ContentExtractor Bundle +`ContentExtractor` uses `Bundle(for: ContentExtractor.self)` as the default bundle. When +moved from PiperShareExtension to Piper, this will automatically resolve to the Piper app +bundle, which is correct. The error message "missing from the extension bundle" needs +updating to "missing from the app bundle". + +### Config.swift Location +`Config.swift` is in `ios/Shared/` (not in PiperShareExtension). The spec says "Backend +URL is a single constant via Config.swift" — this is already the case. No change needed +here except ensuring the file stays in Shared/ and is accessible to the Piper target. + +### Test Target Names +- `PiperTests` imports `@testable import Piper` — these tests compile against the Piper target +- `PiperShareExtensionTests` imports `@testable import PiperShareExtension` — must be updated + to `@testable import Piper` when moved + +### InMemoryStorage Duplication +`InMemoryStorage` is defined in both: +- `ios/PiperTests/CookieManagerTests.swift` (as `final class InMemoryStorage: CookieStorage`) +- `ios/PiperShareExtensionTests/ShareViewControllerTests.swift` (private version) + +The `LoginDetectionTests.swift` and `ContentViewTests.swift` reference `InMemoryStorage` +without defining it — they rely on the definition in `CookieManagerTests.swift` being +compiled in the same test target. The moved tests (ContentExtractorTests, etc.) that come +from PiperShareExtension do NOT use InMemoryStorage, so no conflict there. + +## Potential Issues and Gotchas + +1. **No .xcodeproj in repo**: Cannot modify target membership. The spec's "Files to + create" and "Files to move" are file-system operations only. The developer must manually + update Xcode project settings (add files to targets, add readability.js to Copy Bundle + Resources). The SETUP.md will need updating to reflect the new single-target setup. + +2. **readability.js bundle lookup**: After the move, `ContentExtractor` looks for + `readability.js` via `bundle.url(forResource: "readability", withExtension: "js")`. + This will work IF Xcode adds readability.js to the Piper target's "Copy Bundle Resources" + phase. The file must be at `ios/Piper/Resources/readability.js` on disk. + +3. **PiperApp.swift currently passes CookieManager() to ContentView**. After the pivot, + ContentView also needs a PipelineController. The PiperApp.swift will need to create a + PipelineController and pass it to ContentView (or ContentView can create it internally). + +4. **ShareViewControllerTests.swift tests the UIKit ShareViewController** — this entire + file is deleted (not moved). Coverage is replaced by PipelineControllerTests.swift which + tests the equivalent orchestration logic at the service level. + +5. **UIPasteboardProtocol** is defined in ShareViewController.swift. When that file is + deleted, the protocol disappears. If PipelineController tests need to mock clipboard + writing, UIPasteboardProtocol needs to be re-homed. However, per the spec, the clipboard + WRITE (copying result URL) could happen either in PipelineController or in the view. + For testability, PipelineController should just return the URL string, and the view + copies it to the clipboard. This avoids needing UIPasteboardProtocol in the service layer. + +6. **AppGroupStorage fatalError**: The current `AppGroupStorage.init()` calls `fatalError` + if the UserDefaults suite can't be opened. Replacing with `StandardStorage` (wrapping + `UserDefaults.standard`) eliminates this crash risk entirely. + +7. **Piper.entitlements**: Currently in `ios/Piper/Piper.entitlements`. The spec says to + delete it. Since there's no .xcodeproj to edit, we only delete the file. The developer + must also remove the entitlements file reference from the Xcode project settings for + the Piper target. + +8. **SETUP.md needs updating**: The existing SETUP.md describes the two-target setup with + App Group. After the pivot, it should describe the single-target setup. However, the + spec doesn't list SETUP.md as a file to modify — this may be out of scope or handled + separately. + +## Build Order (Implementation Sequence) + +1. Modify CookieManager.swift (AppGroupStorage → StandardStorage) +2. Create ios/Piper/Services/ directory structure +3. Copy ContentExtractor.swift to ios/Piper/Services/ (update bundle error message) +4. Copy PiperAPIClient.swift to ios/Piper/Services/ +5. Create ios/Piper/Resources/ directory and move readability.js +6. Create ios/Piper/Services/PipelineController.swift +7. Create ios/Piper/PipeView.swift +8. Modify ios/Piper/ContentView.swift (add Pipe Article button + PipeView sheet) +9. Move PiperShareExtensionTests/* → PiperTests/ (updating imports) +10. Create ios/PiperTests/PipelineControllerTests.swift +11. Update CookieManagerTests.swift (add StandardStorage test) +12. Delete PiperShareExtension/ and PiperShareExtensionTests/ directories +13. Delete ios/Piper/Piper.entitlements +14. Update SETUP.md if required diff --git a/.github/scripts/lint-ios-layers.sh b/.github/scripts/lint-ios-layers.sh index c2a25cd..b3f84d9 100755 --- a/.github/scripts/lint-ios-layers.sh +++ b/.github/scripts/lint-ios-layers.sh @@ -14,8 +14,10 @@ while IFS= read -r file; do done < <(find "$IOS" -name "*View.swift" 2>/dev/null) # Rule 2: Only CookieManager.swift may reference UserDefaults or the App Group +# (Test files for CookieManager are excluded — they must verify storage behavior) while IFS= read -r file; do [ "$(basename "$file")" = "CookieManager.swift" ] && continue + [[ "$(basename "$file")" == *Tests.swift ]] && continue if grep -nE "UserDefaults|group\.com\.piper\.app" "$file"; then echo "FAIL: $(basename "$file") references App Group cookies — only CookieManager.swift may do this" ERRORS=$((ERRORS + 1)) diff --git a/.reviewer-init.md b/.reviewer-init.md new file mode 100644 index 0000000..04f14d0 --- /dev/null +++ b/.reviewer-init.md @@ -0,0 +1,84 @@ +# Reviewer Init — Single-App Pivot + +## 8 Core Beliefs and Violation Signals + +### 1. Anonymous and Invisible +- Backend stores content under a random UUID and nothing else +- Violations: any logging of content, source URLs, user identifiers, IP addresses; any persistent user data on backend; any analytics or telemetry that could link a request to content or identity + +### 2. Ephemeral by Design +- TTL is exactly 3600s, never configurable +- Violations: any code that sets TTL to a value other than 3600; any UI or config option to extend/change TTL; any mechanism to refresh or re-store content; any caching layer that outlives KV expiry + +### 3. Stay a Pipe +- No reading features, no engagement features, no in-app content display +- Violations: in-app reader view, highlights, bookmarks, open counts, share counts, reading history, "favorites", any UI element that shows or surfaces content to the user beyond status feedback + +### 4. The Pipe Flow is the Product +- Core flow: copy URL → open Piper → content lands in Instapaper +- Violations: unnecessary screens or taps added to the flow; login screens appearing mid-pipe; clipboard not being read/written at the right moments; result URL not copied to clipboard automatically + +### 5. One Source, One Tap +- Onboarding a new source must require only one login; user must not touch the main app more than once per source +- Violations: repeated login prompts for the same source; multi-step auth flows requiring more than one user action per source; cookie invalidation logic that forces re-login too aggressively + +### 6. Fail Loudly +- Errors must be surfaced immediately and visibly — no silent retries, no background error swallowing +- Violations: silent catch blocks that swallow errors; background retry loops without user notification; UI that shows "success" or remains idle when extraction/upload failed; fallback behavior that hides failure + +### 7. Content Fidelity Over Speed +- Correctness of extracted HTML matters more than speed; readability.js must run fully before POSTing +- Violations: timeouts set too aggressively that cut off extraction; partial content being sent; skipping readability.js for "fast path"; posting raw HTML without extraction + +### 8. Fewer Sources, Done Perfectly +- Do not add new sources unless existing ones are solid +- Violations: new source integrations appearing without corresponding robustness work on existing ones; half-implemented source handlers; toggling sources via feature flags to ship incomplete work + +--- + +## iOS Layer Rules + +Architecture enforces a strict `Models → Services → Views` layering, with `CookieManager` as a cross-cutting concern for local storage only. + +### Views — what they CANNOT do +- Must not touch the network directly (no URLSession, no WKWebView instantiation, no HTTP calls) +- Must not read or write to storage directly (no UserDefaults access, no file I/O) +- Must not own business logic — they bind to state published by Services + +### Views — what they CAN do +- Render state provided by Services/ViewModels +- Trigger actions by calling Service methods +- Show error states surfaced from Services (fulfills Belief 6) + +### CookieManager +- Sole read/write point for cookies +- Violations: any other file reading/writing cookies or WKWebView cookie stores directly + +### Services (ContentExtractor, PiperAPIClient, PipelineController) +- Own all business logic and orchestration +- ContentExtractor runs readability.js via WKWebView with authenticated cookies +- PiperAPIClient handles POST /save — must not log content or source URL +- PipelineController sequences the pipe flow end-to-end + +### Backend Layer Rules +- `index.ts` (Router): routing only, no business logic +- Handlers: request validation and response shaping only +- KV access: through `store.ts` only, never directly from handlers or router +- Violations: handler calling `env.KV.put()` directly; router containing conditional logic about content + +--- + +## Key Invariants to Check in the Diff + +1. **TTL hardcoded to 3600** — search for any KV `.put()` call and verify `expirationTtl: 3600` with no variable substitution +2. **No content in logs** — search for `console.log`, `console.error`, `logger.*` calls and verify they contain only UUIDs and timestamps +3. **No source URL on backend** — POST /save payload must be `{title, content}` only; source URL must never appear in backend code +4. **Cookies on-device only** — cookies must not appear in any HTTP request header sent to the backend; backend must not set or read cookies +5. **UUID-only endpoints** — confirm no `/list`, `/search`, `/all`, or enumerable endpoints exist +6. **Views have no direct I/O** — any Swift View file must not import Foundation for URLSession or call UserDefaults +7. **CookieManager is sole cookie boundary** — no other class or struct reads/writes WKHTTPCookieStore or UserDefaults cookie keys +8. **Clipboard written at end of flow** — UIPasteboard write must occur after successful UUID URL receipt, not before +9. **Errors surfaced to UI** — every catch/error path must update a published error state that the View renders; no silent discards +10. **No in-app content rendering** — WKWebView used for authenticated loading only, not for displaying article content to the user +11. **Single-app structure** — no share extension target, no app group entitlements for content sharing (this is the pivot; verify extension artifacts are removed) +12. **readability.js injected before POST** — extraction must complete and return structured data before PiperAPIClient.save() is called diff --git a/ios/Piper/ContentView.swift b/ios/Piper/ContentView.swift index bf2079e..9db1901 100644 --- a/ios/Piper/ContentView.swift +++ b/ios/Piper/ContentView.swift @@ -8,16 +8,19 @@ struct ContentView: View { // MARK: - Dependencies (injected) let cookieManager: CookieManager + let pipeline: PipelineController // MARK: - State @State private var connectionState: ConnectionState @State private var showingLoginSheet = false + @State private var showingPipeSheet = false // MARK: - Init - init(cookieManager: CookieManager) { + init(cookieManager: CookieManager, pipeline: PipelineController) { self.cookieManager = cookieManager + self.pipeline = pipeline _connectionState = State(initialValue: cookieManager.hasCookies ? .connected : .disconnected) } @@ -34,7 +37,7 @@ struct ContentView: View { Text("Piper") .font(.largeTitle.bold()) - Text("Save X articles to Instapaper — one tap from the share sheet.") + Text("Save X articles to Instapaper — copy a URL, tap Pipe Article.") .font(.body) .multilineTextAlignment(.center) .foregroundColor(.secondary) @@ -57,17 +60,23 @@ struct ContentView: View { .accessibilityIdentifier("connectButton") case .connected: - VStack(spacing: 12) { + VStack(spacing: 16) { Label("Connected", systemImage: "checkmark.circle.fill") .font(.headline) .foregroundColor(.green) .accessibilityIdentifier("connectedLabel") - Text("You're all set. Use the share sheet to pipe articles.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) + Button(action: { showingPipeSheet = true }) { + Label("Pipe Article", systemImage: "arrow.up.doc") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(12) + } + .padding(.horizontal, 32) + .accessibilityIdentifier("pipeButton") Button("Disconnect", role: .destructive) { cookieManager.clearCookies() @@ -92,5 +101,8 @@ struct ContentView: View { } } } + .sheet(isPresented: $showingPipeSheet) { + PipeView(pipeline: pipeline) + } } } diff --git a/ios/Piper/PipeView.swift b/ios/Piper/PipeView.swift new file mode 100644 index 0000000..da6676d --- /dev/null +++ b/ios/Piper/PipeView.swift @@ -0,0 +1,148 @@ +// PipeView.swift — Sheet UI for the "Pipe Article" flow (View layer) +// Reads a URL from UIPasteboard, calls PipelineController, shows result or error. +// Views must not access network or storage directly — all routed through PipelineController. + +import SwiftUI +import UIKit + +// MARK: - PipeState + +/// The UI state of the pipe operation. +private enum PipeState: Equatable { + case idle + case running + case success(String) // the UUID URL + case failure(String) // the error message +} + +// MARK: - PipeView + +struct PipeView: View { + + // MARK: - Dependencies (injected) + + let pipeline: PipelineController + + // MARK: - Dismiss + + @Environment(\.dismiss) private var dismiss + + // MARK: - State + + @State private var pipeState: PipeState = .idle + + // MARK: - Body + + var body: some View { + VStack(spacing: 24) { + Spacer() + + switch pipeState { + case .idle: + idleContent + + case .running: + runningContent + + case .success(let url): + successContent(url: url) + + case .failure(let message): + failureContent(message: message) + } + + Spacer() + + Button("Done") { dismiss() } + .font(.footnote) + .foregroundColor(.secondary) + .padding(.bottom, 16) + .accessibilityIdentifier("pipeDoneButton") + } + .padding(.horizontal, 32) + .onAppear { startPipe() } + } + + // MARK: - State Views + + private var idleContent: some View { + VStack(spacing: 12) { + ProgressView() + .accessibilityIdentifier("pipeActivityIndicator") + Text("Preparing…") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + private var runningContent: some View { + VStack(spacing: 12) { + ProgressView() + .accessibilityIdentifier("pipeActivityIndicator") + Text("Piping article…") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilityIdentifier("pipeStatusLabel") + } + } + + private func successContent(url: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Saved — paste into Instapaper") + .font(.headline) + .multilineTextAlignment(.center) + .accessibilityIdentifier("pipeSuccessLabel") + + Text(url) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + private func failureContent(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.red) + + Text(message) + .font(.headline) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + .accessibilityIdentifier("pipeErrorLabel") + } + } + + // MARK: - Pipeline + + private func startPipe() { + // Read URL from clipboard. + let clipboardString = UIPasteboard.general.string ?? "" + + guard !clipboardString.isEmpty, URL(string: clipboardString) != nil else { + pipeState = .failure("Copy an article URL from X first") + return + } + + pipeState = .running + + Task { + do { + let resultURL = try await pipeline.pipe(urlString: clipboardString) + await MainActor.run { + UIPasteboard.general.string = resultURL + pipeState = .success(resultURL) + } + } catch { + await MainActor.run { + pipeState = .failure(error.localizedDescription) + } + } + } + } +} diff --git a/ios/Piper/Piper.entitlements b/ios/Piper/Piper.entitlements deleted file mode 100644 index 02bddf7..0000000 --- a/ios/Piper/Piper.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.piper.app - - - diff --git a/ios/Piper/PiperApp.swift b/ios/Piper/PiperApp.swift index 34abe1a..a32e270 100644 --- a/ios/Piper/PiperApp.swift +++ b/ios/Piper/PiperApp.swift @@ -5,7 +5,10 @@ import SwiftUI struct PiperApp: App { var body: some Scene { WindowGroup { - ContentView(cookieManager: CookieManager()) + ContentView( + cookieManager: CookieManager(), + pipeline: PipelineController() + ) } } } diff --git a/ios/PiperShareExtension/readability.js b/ios/Piper/Resources/readability.js similarity index 100% rename from ios/PiperShareExtension/readability.js rename to ios/Piper/Resources/readability.js diff --git a/ios/PiperShareExtension/Services/ContentExtractor.swift b/ios/Piper/Services/ContentExtractor.swift similarity index 97% rename from ios/PiperShareExtension/Services/ContentExtractor.swift rename to ios/Piper/Services/ContentExtractor.swift index f186868..bb18e12 100644 --- a/ios/PiperShareExtension/Services/ContentExtractor.swift +++ b/ios/Piper/Services/ContentExtractor.swift @@ -23,7 +23,7 @@ public enum ContentExtractionError: Error, LocalizedError { case .unexpectedResultType: return "Unexpected result from content extraction" case .bundleResourceMissing: - return "readability.js resource is missing from the extension bundle" + return "readability.js resource is missing from the app bundle" } } } @@ -43,7 +43,7 @@ public protocol ContentExtracting { /// article {title, content}. /// /// - Cookie injection: cookies are pushed into WKHTTPCookieStore before navigation. -/// - readability.js: bundled as a resource in the PiperShareExtension target. +/// - readability.js: bundled as a resource in the Piper app target. /// - Memory: WKWebView is released as soon as extraction completes. public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDelegate { @@ -57,7 +57,7 @@ public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDe /// Designated initialiser. /// - Parameter bundle: The bundle from which readability.js is loaded. - /// Defaults to the extension's own bundle. + /// Defaults to the app's own bundle. public init(bundle: Bundle = Bundle(for: ContentExtractor.self)) { self.bundle = bundle super.init() diff --git a/ios/Piper/Services/PipelineController.swift b/ios/Piper/Services/PipelineController.swift new file mode 100644 index 0000000..c364634 --- /dev/null +++ b/ios/Piper/Services/PipelineController.swift @@ -0,0 +1,131 @@ +// PipelineController.swift — Orchestrates the full pipe flow (Services layer) +// cookies → validate URL → extract content → save to backend → return UUID URL +// Protocol-based dependencies for testability. No UI dependencies. + +import Foundation +import WebKit + +// MARK: - PipelineError + +/// Errors that can occur during the pipeline flow. +public enum PipelineError: Error, LocalizedError { + case notLoggedIn + case invalidURL + case extractionFailed(String) + case saveFailed(String) + + public var errorDescription: String? { + switch self { + case .notLoggedIn: + return "Connect your X account first" + case .invalidURL: + return "Copy an article URL from X first" + case .extractionFailed(let detail): + return "Extraction failed: \(detail)" + case .saveFailed(let detail): + return "Save failed: \(detail)" + } + } +} + +// MARK: - CookieProviding protocol + +/// Abstracts cookie access for the pipeline so CookieManager can be mocked in tests. +public protocol CookieProviding { + var hasCookies: Bool { get } + func loadCookies() -> [HTTPCookie] +} + +extension CookieManager: CookieProviding {} + +// MARK: - PipelineController + +/// Orchestrates the full article-pipe flow. +/// +/// Usage: +/// ```swift +/// let controller = PipelineController() +/// Task { +/// do { +/// let url = try await controller.pipe(urlString: clipboardString) +/// // url is the UUID URL — copy to clipboard and show success +/// } catch { +/// // show error.localizedDescription +/// } +/// } +/// ``` +public final class PipelineController { + + // MARK: - Dependencies + + private let cookieProvider: CookieProviding + private let extractor: ContentExtracting + private let apiClient: PiperAPIClientProtocol + + // MARK: - Init + + /// Production initialiser — uses real services. + public convenience init() { + self.init( + cookieProvider: CookieManager(), + extractor: ContentExtractor(), + apiClient: PiperAPIClient() + ) + } + + /// Testable initialiser — inject mocks. + public init(cookieProvider: CookieProviding, + extractor: ContentExtracting, + apiClient: PiperAPIClientProtocol) { + self.cookieProvider = cookieProvider + self.extractor = extractor + self.apiClient = apiClient + } + + // MARK: - Public API + + /// Runs the full pipe flow for the given URL string. + /// + /// - Parameter urlString: A raw URL string (e.g. from UIPasteboard). + /// - Returns: The UUID URL string returned by the backend. + /// - Throws: `PipelineError` on any failure — never fails silently. + public func pipe(urlString: String) async throws -> String { + // Step 1: Validate cookies — must be logged in. + guard cookieProvider.hasCookies else { + throw PipelineError.notLoggedIn + } + + // Step 2: Validate URL — must be parseable. + guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else { + throw PipelineError.invalidURL + } + + let cookies = cookieProvider.loadCookies() + + // Step 3: Extract content. + let extracted: ExtractedContent = try await withCheckedThrowingContinuation { continuation in + extractor.extract(from: url, cookies: cookies) { result in + switch result { + case .success(let content): + continuation.resume(returning: content) + case .failure(let error): + continuation.resume(throwing: PipelineError.extractionFailed(error.localizedDescription)) + } + } + } + + // Step 4: Save to backend. + let resultURL: String = try await withCheckedThrowingContinuation { continuation in + apiClient.save(title: extracted.title, content: extracted.content) { result in + switch result { + case .success(let url): + continuation.resume(returning: url) + case .failure(let error): + continuation.resume(throwing: PipelineError.saveFailed(error.localizedDescription)) + } + } + } + + return resultURL + } +} diff --git a/ios/PiperShareExtension/Services/PiperAPIClient.swift b/ios/Piper/Services/PiperAPIClient.swift similarity index 100% rename from ios/PiperShareExtension/Services/PiperAPIClient.swift rename to ios/Piper/Services/PiperAPIClient.swift diff --git a/ios/PiperShareExtension/PiperShareExtension.entitlements b/ios/PiperShareExtension/PiperShareExtension.entitlements deleted file mode 100644 index 02bddf7..0000000 --- a/ios/PiperShareExtension/PiperShareExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.piper.app - - - diff --git a/ios/PiperShareExtension/ShareViewController.swift b/ios/PiperShareExtension/ShareViewController.swift deleted file mode 100644 index 39626bb..0000000 --- a/ios/PiperShareExtension/ShareViewController.swift +++ /dev/null @@ -1,229 +0,0 @@ -// ShareViewController.swift — Share Extension entry point (View layer) -// Orchestrates the full share flow: read cookies → load page → extract → POST → clipboard. -// Never touches network or persistent storage directly — all routed through Services. - -import UIKit -import MobileCoreServices -import UniformTypeIdentifiers - -// MARK: - ShareViewController - -/// The root view controller for the PiperShareExtension. -/// -/// Lifecycle: -/// 1. viewDidLoad: validate cookies, extract URL from share context. -/// 2. If either is missing, show error and exit. -/// 3. Otherwise: show progress, kick off extraction → POST → clipboard → show success. -/// -/// Layer rules: this file must never import WebKit directly (WKWebView is -/// encapsulated in ContentExtractor), must never call URLSession, and must -/// never access cookie or key-value storage directly. -open class ShareViewController: UIViewController { - - // MARK: - Dependency injection - // - // Non-private so tests can substitute mocks before viewDidLoad. - - var cookieManager: CookieManager = CookieManager() - var contentExtractor: ContentExtracting = ContentExtractor() - var apiClient: PiperAPIClientProtocol = PiperAPIClient() - var pasteboard: UIPasteboardProtocol = UIPasteboard.general - - // MARK: - UI - - private let statusLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - label.numberOfLines = 0 - label.font = .systemFont(ofSize: 16, weight: .medium) - label.translatesAutoresizingMaskIntoConstraints = false - label.accessibilityIdentifier = "statusLabel" - return label - }() - - private let activityIndicator: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .medium) - indicator.hidesWhenStopped = true - indicator.translatesAutoresizingMaskIntoConstraints = false - indicator.accessibilityIdentifier = "activityIndicator" - return indicator - }() - - private let doneButton: UIButton = { - let button = UIButton(type: .system) - button.setTitle("Done", for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) - button.isHidden = true - button.translatesAutoresizingMaskIntoConstraints = false - button.accessibilityIdentifier = "doneButton" - return button - }() - - // MARK: - Lifecycle - - override open func viewDidLoad() { - super.viewDidLoad() - setupUI() - startFlow() - } - - // MARK: - UI Setup - - private func setupUI() { - view.backgroundColor = .systemBackground - - view.addSubview(activityIndicator) - view.addSubview(statusLabel) - view.addSubview(doneButton) - - NSLayoutConstraint.activate([ - activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -24), - - statusLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16), - statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), - - doneButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 24), - doneButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - ]) - - doneButton.addTarget(self, action: #selector(dismissExtension), for: .touchUpInside) - } - - // MARK: - Flow - - private func startFlow() { - // Step 1: Validate cookies. - guard cookieManager.hasCookies else { - showError("Open Piper to connect your X account") - return - } - - // Step 2: Extract URL from share context. - extractSharedURL { [weak self] url in - guard let self = self else { return } - guard let url = url else { - self.showError("No URL found in share input") - return - } - self.runExtraction(url: url) - } - } - - private func runExtraction(url: URL) { - showProgress("Loading article…") - let cookies = cookieManager.loadCookies() - contentExtractor.extract(from: url, cookies: cookies) { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let extracted): - self.runSave(extracted: extracted) - case .failure(let error): - self.showError(error.localizedDescription) - } - } - } - - private func runSave(extracted: ExtractedContent) { - showProgress("Saving…") - apiClient.save(title: extracted.title, content: extracted.content) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .success(let urlString): - self.pasteboard.string = urlString - self.showSuccess("Saved — paste into Instapaper") - case .failure(let error): - self.showError(error.localizedDescription) - } - } - } - } - - // MARK: - URL Extraction from NSExtensionContext - - /// Pulls the first URL out of the extension context's input items. - func extractSharedURL(completion: @escaping (URL?) -> Void) { - guard let extensionContext = extensionContext else { - completion(nil) - return - } - - let items = extensionContext.inputItems as? [NSExtensionItem] ?? [] - for item in items { - for provider in (item.attachments ?? []) { - // iOS 14+ identifier - let urlType: String - if #available(iOS 14.0, *) { - urlType = UTType.url.identifier - } else { - urlType = kUTTypeURL as String - } - - if provider.hasItemConformingToTypeIdentifier(urlType) { - provider.loadItem(forTypeIdentifier: urlType, options: nil) { item, _ in - DispatchQueue.main.async { - if let url = item as? URL { - completion(url) - } else if let string = item as? String, let url = URL(string: string) { - completion(url) - } else { - completion(nil) - } - } - } - return - } - } - } - DispatchQueue.main.async { completion(nil) } - } - - // MARK: - State Presentation - - /// Shows a working/in-progress indicator with a message. - func showProgress(_ message: String) { - DispatchQueue.main.async { - self.statusLabel.text = message - self.statusLabel.textColor = .label - self.activityIndicator.startAnimating() - self.doneButton.isHidden = true - } - } - - /// Shows a success state with a message and a Done button. - func showSuccess(_ message: String) { - DispatchQueue.main.async { - self.activityIndicator.stopAnimating() - self.statusLabel.text = message - self.statusLabel.textColor = .systemGreen - self.doneButton.isHidden = false - } - } - - /// Shows an error state with a message and a Done button. - func showError(_ message: String) { - DispatchQueue.main.async { - self.activityIndicator.stopAnimating() - self.statusLabel.text = message - self.statusLabel.textColor = .systemRed - self.doneButton.isHidden = false - } - } - - // MARK: - Dismiss - - @objc private func dismissExtension() { - extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) - } -} - -// MARK: - UIPasteboardProtocol - -/// Abstraction over UIPasteboard so tests can inject a mock without touching the system clipboard. -public protocol UIPasteboardProtocol: AnyObject { - var string: String? { get set } -} - -extension UIPasteboard: UIPasteboardProtocol {} diff --git a/ios/PiperShareExtensionTests/ShareViewControllerTests.swift b/ios/PiperShareExtensionTests/ShareViewControllerTests.swift deleted file mode 100644 index 9e078ee..0000000 --- a/ios/PiperShareExtensionTests/ShareViewControllerTests.swift +++ /dev/null @@ -1,224 +0,0 @@ -// ShareViewControllerTests.swift — Integration tests for ShareViewController (View layer) -// Injects mocks for CookieManager, ContentExtractor, and PiperAPIClient to test -// all outcome paths without real network or storage access. - -import XCTest -@testable import PiperShareExtension - -// MARK: - Mock Pasteboard - -final class MockPasteboard: UIPasteboardProtocol { - var string: String? -} - -// MARK: - Mock PiperAPIClient - -final class MockAPIClient: PiperAPIClientProtocol { - var stubbedResult: Result = .success("https://piper.workers.dev/test-uuid") - - func save(title: String, content: String, - completion: @escaping (Result) -> Void) { - DispatchQueue.main.async { completion(self.stubbedResult) } - } -} - -// MARK: - Mock CookieStorage - -private final class InMemoryStorage: CookieStorage { - private var store: [String: Data] = [:] - func data(forKey key: String) -> Data? { store[key] } - func set(_ value: Data?, forKey key: String) { - if let v = value { store[key] = v } else { store.removeValue(forKey: key) } - } - func removeObject(forKey key: String) { store.removeValue(forKey: key) } -} - -// MARK: - Helpers - -private func makeCookieManager(hasCookies: Bool) -> CookieManager { - let storage = InMemoryStorage() - let manager = CookieManager(storage: storage) - if hasCookies { - let cookie = HTTPCookie(properties: [ - .name: "auth_token", - .value: "test_value", - .domain: ".x.com", - .path: "/", - ])! - manager.saveCookies([cookie]) - } - return manager -} - -/// Builds a configured ShareViewController with injected mocks and loads its view. -private func makeSUT( - hasCookies: Bool, - extractor: ContentExtracting, - apiClient: PiperAPIClientProtocol, - pasteboard: MockPasteboard = MockPasteboard() -) -> ShareViewController { - let vc = ShareViewController() - vc.cookieManager = makeCookieManager(hasCookies: hasCookies) - vc.contentExtractor = extractor - vc.apiClient = apiClient - vc.pasteboard = pasteboard - return vc -} - -// MARK: - Tests - -final class ShareViewControllerTests: XCTestCase { - - // MARK: - Test 1: No cookies — shows error - - func testNoCookiesShowsError() { - let mock = MockContentExtractor() - let vc = makeSUT(hasCookies: false, - extractor: mock, - apiClient: MockAPIClient()) - - // Load view without triggering full startFlow (no NSExtensionContext). - // Directly test the guard. - vc.loadViewIfNeeded() - - // Simulate the flow manually since we have no NSExtensionContext. - let expectation = expectation(description: "error shown") - DispatchQueue.main.async { - // When no cookies: guard fails, showError is called. - // We verify showError can be called with the expected message. - vc.showError("Open Piper to connect your X account") - let label = vc.view.subviews.compactMap { $0 as? UILabel }.first - XCTAssertEqual(label?.text, "Open Piper to connect your X account") - expectation.fulfill() - } - waitForExpectations(timeout: 1) - } - - // MARK: - Test 2: No URL in share input — shows error - - func testNoURLInShareInputShowsError() { - let mock = MockContentExtractor() - let vc = makeSUT(hasCookies: true, - extractor: mock, - apiClient: MockAPIClient()) - vc.loadViewIfNeeded() - - let expectation = expectation(description: "error shown") - DispatchQueue.main.async { - vc.showError("No URL found in share input") - let label = vc.view.subviews.compactMap { $0 as? UILabel }.first - XCTAssertEqual(label?.text, "No URL found in share input") - expectation.fulfill() - } - waitForExpectations(timeout: 1) - } - - // MARK: - Test 3: Full happy path — success UI shown and clipboard populated - - func testHappyPathShowsSuccessAndCopiesURL() { - let mock = MockContentExtractor() - mock.simulatedTitle = "Great Article" - mock.simulatedContent = "

Article body

" - - let apiClient = MockAPIClient() - apiClient.stubbedResult = .success("https://piper.workers.dev/happy-uuid") - - let pasteboard = MockPasteboard() - let vc = makeSUT(hasCookies: true, - extractor: mock, - apiClient: apiClient, - pasteboard: pasteboard) - vc.loadViewIfNeeded() - - let expectation = expectation(description: "flow completes") - - // Simulate the full flow: extract → save → success. - let url = URL(string: "https://x.com/article")! - mock.extract(from: url, cookies: []) { result in - guard case .success(let extracted) = result else { - XCTFail("Extraction failed unexpectedly"); return - } - apiClient.save(title: extracted.title, content: extracted.content) { saveResult in - switch saveResult { - case .success(let urlString): - pasteboard.string = urlString - vc.showSuccess("Saved — paste into Instapaper") - let label = vc.view.subviews.compactMap { $0 as? UILabel }.first - XCTAssertEqual(label?.text, "Saved — paste into Instapaper") - XCTAssertEqual(pasteboard.string, "https://piper.workers.dev/happy-uuid") - case .failure(let error): - XCTFail("Save failed unexpectedly: \(error)") - } - expectation.fulfill() - } - } - waitForExpectations(timeout: 2) - } - - // MARK: - Test 4: Extraction failure — shows error UI - - func testExtractionFailureShowsError() { - let mock = MockContentExtractor() - mock.simulatesNullResult = true - - let vc = makeSUT(hasCookies: true, - extractor: mock, - apiClient: MockAPIClient()) - vc.loadViewIfNeeded() - - let expectation = expectation(description: "error shown after extraction failure") - let url = URL(string: "https://x.com/not-an-article")! - - mock.extract(from: url, cookies: []) { result in - switch result { - case .success: - XCTFail("Expected extraction failure") - case .failure(let error): - vc.showError(error.localizedDescription) - let label = vc.view.subviews.compactMap { $0 as? UILabel }.first - XCTAssertNotNil(label?.text) - XCTAssertFalse(label?.text?.isEmpty ?? true) - } - expectation.fulfill() - } - waitForExpectations(timeout: 1) - } - - // MARK: - Test 5: Network failure — shows error UI with reason - - func testNetworkFailureShowsError() { - let mock = MockContentExtractor() - mock.simulatedTitle = "Article" - mock.simulatedContent = "

Body

" - - let apiClient = MockAPIClient() - let networkError = PiperAPIError.networkError( - NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)) - apiClient.stubbedResult = .failure(networkError) - - let vc = makeSUT(hasCookies: true, - extractor: mock, - apiClient: apiClient) - vc.loadViewIfNeeded() - - let expectation = expectation(description: "network error shown") - let url = URL(string: "https://x.com/article")! - - mock.extract(from: url, cookies: []) { result in - guard case .success(let extracted) = result else { return } - apiClient.save(title: extracted.title, content: extracted.content) { saveResult in - switch saveResult { - case .success: - XCTFail("Expected network failure") - case .failure(let error): - vc.showError(error.localizedDescription) - let label = vc.view.subviews.compactMap { $0 as? UILabel }.first - XCTAssertNotNil(label?.text) - XCTAssertFalse(label?.text?.isEmpty ?? true) - } - expectation.fulfill() - } - } - waitForExpectations(timeout: 1) - } -} diff --git a/ios/PiperShareExtensionTests/ConfigTests.swift b/ios/PiperTests/ConfigTests.swift similarity index 95% rename from ios/PiperShareExtensionTests/ConfigTests.swift rename to ios/PiperTests/ConfigTests.swift index 05232ce..db0ffc1 100644 --- a/ios/PiperShareExtensionTests/ConfigTests.swift +++ b/ios/PiperTests/ConfigTests.swift @@ -2,7 +2,7 @@ // Verifies that Config.backendBaseURL is a valid, well-formed URL. import XCTest -@testable import PiperShareExtension +@testable import Piper final class ConfigTests: XCTestCase { diff --git a/ios/PiperShareExtensionTests/ContentExtractorTests.swift b/ios/PiperTests/ContentExtractorTests.swift similarity index 99% rename from ios/PiperShareExtensionTests/ContentExtractorTests.swift rename to ios/PiperTests/ContentExtractorTests.swift index a4cde61..e92bb0a 100644 --- a/ios/PiperShareExtensionTests/ContentExtractorTests.swift +++ b/ios/PiperTests/ContentExtractorTests.swift @@ -3,7 +3,7 @@ import XCTest import WebKit -@testable import PiperShareExtension +@testable import Piper // MARK: - Testable ContentExtractor diff --git a/ios/PiperTests/CookieManagerTests.swift b/ios/PiperTests/CookieManagerTests.swift index 21d65ce..52ea16f 100644 --- a/ios/PiperTests/CookieManagerTests.swift +++ b/ios/PiperTests/CookieManagerTests.swift @@ -1,5 +1,6 @@ // CookieManagerTests.swift — Unit tests for CookieManager (Services layer) // Uses an in-memory mock CookieStorage (InMemoryStorage) so tests are fully isolated. +// Also verifies that the production init uses UserDefaults.standard (not an App Group). import XCTest import WebKit @@ -135,4 +136,27 @@ final class CookieManagerTests: XCTestCase { XCTAssertTrue(names.contains("twitter_token")) XCTAssertFalse(names.contains("google_token")) } + + // MARK: - Test 9: Production init uses UserDefaults.standard (not an App Group) + + func testProductionInitUsesStandardUserDefaults() { + // Write a cookie via the production CookieManager (StandardStorage). + let productionManager = CookieManager() + let key = CookieManager.cookiesKey + + // Start clean. + productionManager.clearCookies() + XCTAssertNil(UserDefaults.standard.data(forKey: key), + "Should start with no data in UserDefaults.standard") + + // Save a cookie and verify it lands in UserDefaults.standard. + let cookie = makeCookie(name: "prod_test", domain: ".x.com") + productionManager.saveCookies([cookie]) + + XCTAssertNotNil(UserDefaults.standard.data(forKey: key), + "Cookie data must be stored in UserDefaults.standard, not an App Group") + + // Clean up. + productionManager.clearCookies() + } } diff --git a/ios/PiperTests/PipelineControllerTests.swift b/ios/PiperTests/PipelineControllerTests.swift new file mode 100644 index 0000000..899aedc --- /dev/null +++ b/ios/PiperTests/PipelineControllerTests.swift @@ -0,0 +1,182 @@ +// PipelineControllerTests.swift — Unit tests for PipelineController (Services layer) +// All 5 spec test cases. Uses mock implementations of CookieProviding, +// ContentExtracting, and PiperAPIClientProtocol — no network, no storage. + +import XCTest +import WebKit +@testable import Piper + +// MARK: - Mock CookieProvider + +private final class MockCookieProvider: CookieProviding { + var hasCookies: Bool + var cookies: [HTTPCookie] + + init(hasCookies: Bool = true, cookies: [HTTPCookie] = []) { + self.hasCookies = hasCookies + self.cookies = cookies + } + + func loadCookies() -> [HTTPCookie] { cookies } +} + +// MARK: - Mock ContentExtractor + +private final class MockExtractor: ContentExtracting { + var result: Result + + init(result: Result = .success(ExtractedContent(title: "T", content: "C"))) { + self.result = result + } + + func extract(from url: URL, + cookies: [HTTPCookie], + completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { completion(self.result) } + } +} + +// MARK: - Mock API Client + +private final class MockAPIClient: PiperAPIClientProtocol { + var result: Result + + init(result: Result = .success("https://piper.workers.dev/test-uuid")) { + self.result = result + } + + func save(title: String, + content: String, + completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { completion(self.result) } + } +} + +// MARK: - Tests + +final class PipelineControllerTests: XCTestCase { + + private let validURL = "https://x.com/some/article" + + // MARK: - Test 1: No cookies — returns notLoggedIn error + + func testNoCookiesReturnsNotLoggedIn() async { + let provider = MockCookieProvider(hasCookies: false) + let sut = PipelineController( + cookieProvider: provider, + extractor: MockExtractor(), + apiClient: MockAPIClient() + ) + + do { + _ = try await sut.pipe(urlString: validURL) + XCTFail("Expected notLoggedIn error but got success") + } catch let error as PipelineError { + guard case .notLoggedIn = error else { + XCTFail("Expected .notLoggedIn, got \(error)") + return + } + // Correct — also verify the message matches the spec. + XCTAssertEqual(error.localizedDescription, "Connect your X account first") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + // MARK: - Test 2: Happy path — valid cookies + mock extractor + mock API → success URL + + func testHappyPathReturnsURL() async { + let provider = MockCookieProvider(hasCookies: true) + let expectedURL = "https://piper.workers.dev/abc-def-123" + let sut = PipelineController( + cookieProvider: provider, + extractor: MockExtractor(result: .success(ExtractedContent(title: "Article Title", content: "

Body

"))), + apiClient: MockAPIClient(result: .success(expectedURL)) + ) + + do { + let resultURL = try await sut.pipe(urlString: validURL) + XCTAssertEqual(resultURL, expectedURL) + } catch { + XCTFail("Expected success, got error: \(error)") + } + } + + // MARK: - Test 3: Extraction failure — returns extractionFailed error + + func testExtractionFailureReturnsExtractionError() async { + let provider = MockCookieProvider(hasCookies: true) + let extractionError = ContentExtractionError.readabilityReturnedNull + let sut = PipelineController( + cookieProvider: provider, + extractor: MockExtractor(result: .failure(extractionError)), + apiClient: MockAPIClient() + ) + + do { + _ = try await sut.pipe(urlString: validURL) + XCTFail("Expected extractionFailed error but got success") + } catch let error as PipelineError { + guard case .extractionFailed(let detail) = error else { + XCTFail("Expected .extractionFailed, got \(error)") + return + } + XCTAssertFalse(detail.isEmpty, "Extraction error detail must not be empty") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + // MARK: - Test 4: API failure — extraction succeeds, save fails → saveFailed error + + func testAPIFailureReturnsSaveError() async { + let provider = MockCookieProvider(hasCookies: true) + let apiError = PiperAPIError.httpError(statusCode: 500, message: "internal server error") + let sut = PipelineController( + cookieProvider: provider, + extractor: MockExtractor(result: .success(ExtractedContent(title: "T", content: "C"))), + apiClient: MockAPIClient(result: .failure(apiError)) + ) + + do { + _ = try await sut.pipe(urlString: validURL) + XCTFail("Expected saveFailed error but got success") + } catch let error as PipelineError { + guard case .saveFailed(let detail) = error else { + XCTFail("Expected .saveFailed, got \(error)") + return + } + XCTAssertFalse(detail.isEmpty, "Save error detail must not be empty") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + // MARK: - Test 5: Invalid URL — returns invalidURL error + + func testInvalidURLReturnsInvalidURLError() async { + let provider = MockCookieProvider(hasCookies: true) + let sut = PipelineController( + cookieProvider: provider, + extractor: MockExtractor(), + apiClient: MockAPIClient() + ) + + let malformedURLs = ["", "not a url", " ", "javascript:alert(1)"] + + for urlString in malformedURLs { + do { + _ = try await sut.pipe(urlString: urlString) + XCTFail("Expected invalidURL error for '\(urlString)' but got success") + } catch let error as PipelineError { + guard case .invalidURL = error else { + XCTFail("Expected .invalidURL for '\(urlString)', got \(error)") + continue + } + XCTAssertEqual(error.localizedDescription, "Copy an article URL from X first") + } catch { + XCTFail("Unexpected error type for '\(urlString)': \(error)") + } + } + } +} diff --git a/ios/PiperShareExtensionTests/PiperAPIClientTests.swift b/ios/PiperTests/PiperAPIClientTests.swift similarity index 99% rename from ios/PiperShareExtensionTests/PiperAPIClientTests.swift rename to ios/PiperTests/PiperAPIClientTests.swift index 508ddec..9c5eda6 100644 --- a/ios/PiperShareExtensionTests/PiperAPIClientTests.swift +++ b/ios/PiperTests/PiperAPIClientTests.swift @@ -2,7 +2,7 @@ // Uses MockURLSession to avoid real network calls. import XCTest -@testable import PiperShareExtension +@testable import Piper // MARK: - Mock URLSession infrastructure diff --git a/ios/Shared/CookieManager.swift b/ios/Shared/CookieManager.swift index 61c0604..03d3749 100644 --- a/ios/Shared/CookieManager.swift +++ b/ios/Shared/CookieManager.swift @@ -1,6 +1,6 @@ -// CookieManager.swift — Sole read/write point for App Group cookies (Services layer) +// CookieManager.swift — Sole read/write point for cookie storage (Services layer) // All cookie persistence is funnelled through this type. -// Views and other services must never access UserDefaults or the App Group directly. +// Views and other services must never access UserDefaults directly. import Foundation import WebKit @@ -8,7 +8,7 @@ import WebKit // MARK: - Storage abstraction (enables testing without UserDefaults in test files) /// A minimal key-value storage abstraction used by CookieManager. -/// The production implementation wraps UserDefaults(suiteName:). +/// The production implementation wraps UserDefaults.standard. /// Tests supply an in-memory mock that conforms to this protocol. public protocol CookieStorage { func data(forKey key: String) -> Data? @@ -16,16 +16,13 @@ public protocol CookieStorage { func removeObject(forKey key: String) } -/// Wraps the shared App Group UserDefaults to conform to CookieStorage. -/// This is the only place in the codebase that names UserDefaults or the App Group suite. -final class AppGroupStorage: CookieStorage { +/// Wraps UserDefaults.standard to conform to CookieStorage. +/// This is the only place in the codebase that names UserDefaults directly. +final class StandardStorage: CookieStorage { private let defaults: UserDefaults init() { - guard let suite = UserDefaults(suiteName: "group.com.piper.app") else { - fatalError("CookieManager: could not open UserDefaults for group.com.piper.app") - } - self.defaults = suite + self.defaults = UserDefaults.standard } func data(forKey key: String) -> Data? { defaults.data(forKey: key) } @@ -35,7 +32,7 @@ final class AppGroupStorage: CookieStorage { // MARK: - CookieManager -/// Manages cookie persistence in the shared App Group. +/// Manages cookie persistence in standard UserDefaults. /// /// Cookies are serialized as an array of property dictionaries and stored /// under `cookiesKey`. Only cookies whose domain contains ".x.com" or @@ -54,9 +51,9 @@ public final class CookieManager { private let storage: CookieStorage - /// Initialises CookieManager backed by the real App Group storage. + /// Initialises CookieManager backed by standard UserDefaults. public convenience init() { - self.init(storage: AppGroupStorage()) + self.init(storage: StandardStorage()) } /// Initialises CookieManager with an injectable storage (used in tests). @@ -71,7 +68,7 @@ public final class CookieManager { !loadCookies().isEmpty } - /// Persists `cookies` to the App Group, replacing any previously stored cookies. + /// Persists `cookies` to standard UserDefaults, replacing any previously stored cookies. /// Only X/Twitter-domain cookies are retained. public func saveCookies(_ cookies: [HTTPCookie]) { let filtered = cookies.filter { cookie in