From 37da0a5a8d111e2cf032f94e4f9a5e8cb9d870fc Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Mon, 16 Mar 2026 19:12:07 +0500 Subject: [PATCH 1/2] refactor: extract FigmaAPI into standalone package swift-figma-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace local FigmaAPI module with external dependency from github.com/DesignPipe/swift-figma-api (0.1.0). Changes: - Remove Sources/FigmaAPI/ and Tests/FigmaAPITests/ (now in swift-figma-api) - Add swift-figma-api 0.1.0 dependency to Package.swift - Update ExFigCLI and ExFigTests to use .product(name:package:) - Update CLAUDE.md: 13→12 modules, add swift-figma-api to dependencies - Update testing-workflow.md: remove FigmaAPITests target --- .claude/rules/testing-workflow.md | 1 - CLAUDE.md | 8 +- Package.resolved | 11 +- Package.swift | 24 +- Sources/FigmaAPI/AGENTS.md | 1 - Sources/FigmaAPI/CLAUDE.md | 120 ---- Sources/FigmaAPI/Client.swift | 108 ---- Sources/FigmaAPI/Endpoint/BaseEndpoint.swift | 38 -- .../Endpoint/ComponentsEndpoint.swift | 72 --- Sources/FigmaAPI/Endpoint/Endpoint.swift | 25 - .../Endpoint/FileMetadataEndpoint.swift | 57 -- Sources/FigmaAPI/Endpoint/ImageEndpoint.swift | 101 ---- .../Endpoint/LatestReleaseEndpoint.swift | 21 - Sources/FigmaAPI/Endpoint/NodesEndpoint.swift | 36 -- .../FigmaAPI/Endpoint/StylesEndpoint.swift | 26 - .../Endpoint/UpdateVariablesEndpoint.swift | 33 -- .../FigmaAPI/Endpoint/VariablesEndpoint.swift | 27 - Sources/FigmaAPI/FigmaAPIError.swift | 171 ------ Sources/FigmaAPI/FigmaClient.swift | 16 - Sources/FigmaAPI/GitHubClient.swift | 15 - Sources/FigmaAPI/Model/FigmaClientError.swift | 16 - .../FigmaAPI/Model/FloatNormalization.swift | 21 - Sources/FigmaAPI/Model/Node.swift | 272 --------- .../Model/NodeHashableProperties.swift | 143 ----- Sources/FigmaAPI/Model/Style.swift | 37 -- Sources/FigmaAPI/Model/VariableUpdate.swift | 51 -- Sources/FigmaAPI/Model/Variables.swift | 83 --- Sources/FigmaAPI/RateLimitedClient.swift | 105 ---- Sources/FigmaAPI/RetryPolicy.swift | 103 ---- Sources/FigmaAPI/SharedRateLimiter.swift | 246 --------- .../ComponentsEndpointTests.swift | 78 --- .../DocumentHashChildrenOrderTests.swift | 117 ---- .../DocumentHashConversionTests.swift | 344 ------------ .../EndpointMakeRequestTests.swift | 45 -- Tests/FigmaAPITests/FigmaAPIErrorTests.swift | 227 -------- .../FigmaAPITests/FigmaClientErrorTests.swift | 40 -- .../FileMetadataEndpointTests.swift | 83 --- .../Fixtures/ComponentsResponse.json | 50 -- .../Fixtures/FileMetadataResponse.json | 13 - .../FigmaAPITests/Fixtures/ImageResponse.json | 8 - .../FigmaAPITests/Fixtures/NodesResponse.json | 80 --- .../Fixtures/StylesResponse.json | 43 -- .../Fixtures/VariablesResponse.json | 41 -- .../FigmaAPITests/Helpers/FixtureLoader.swift | 36 -- Tests/FigmaAPITests/ImageEndpointTests.swift | 116 ---- Tests/FigmaAPITests/MockClientTests.swift | 269 --------- Tests/FigmaAPITests/Mocks/MockClient.swift | 138 ----- .../NodeHashablePropertiesTests.swift | 255 --------- Tests/FigmaAPITests/NodeTests.swift | 257 --------- Tests/FigmaAPITests/NodesEndpointTests.swift | 121 ---- .../RateLimitedClientTests.swift | 521 ------------------ .../RedirectGuardDelegateTests.swift | 177 ------ Tests/FigmaAPITests/RetryPolicyTests.swift | 256 --------- .../SharedRateLimiterTests.swift | 291 ---------- Tests/FigmaAPITests/StylesEndpointTests.swift | 94 ---- .../UpdateVariablesEndpointTests.swift | 99 ---- Tests/FigmaAPITests/VariableUpdateTests.swift | 157 ------ .../VariablesEndpointTests.swift | 167 ------ Tests/FigmaAPITests/VariablesTests.swift | 237 -------- 59 files changed, 17 insertions(+), 6331 deletions(-) delete mode 120000 Sources/FigmaAPI/AGENTS.md delete mode 100644 Sources/FigmaAPI/CLAUDE.md delete mode 100644 Sources/FigmaAPI/Client.swift delete mode 100644 Sources/FigmaAPI/Endpoint/BaseEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/Endpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/ImageEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/LatestReleaseEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/NodesEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/StylesEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/UpdateVariablesEndpoint.swift delete mode 100644 Sources/FigmaAPI/Endpoint/VariablesEndpoint.swift delete mode 100644 Sources/FigmaAPI/FigmaAPIError.swift delete mode 100644 Sources/FigmaAPI/FigmaClient.swift delete mode 100644 Sources/FigmaAPI/GitHubClient.swift delete mode 100644 Sources/FigmaAPI/Model/FigmaClientError.swift delete mode 100644 Sources/FigmaAPI/Model/FloatNormalization.swift delete mode 100644 Sources/FigmaAPI/Model/Node.swift delete mode 100644 Sources/FigmaAPI/Model/NodeHashableProperties.swift delete mode 100644 Sources/FigmaAPI/Model/Style.swift delete mode 100644 Sources/FigmaAPI/Model/VariableUpdate.swift delete mode 100644 Sources/FigmaAPI/Model/Variables.swift delete mode 100644 Sources/FigmaAPI/RateLimitedClient.swift delete mode 100644 Sources/FigmaAPI/RetryPolicy.swift delete mode 100644 Sources/FigmaAPI/SharedRateLimiter.swift delete mode 100644 Tests/FigmaAPITests/ComponentsEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/DocumentHashChildrenOrderTests.swift delete mode 100644 Tests/FigmaAPITests/DocumentHashConversionTests.swift delete mode 100644 Tests/FigmaAPITests/EndpointMakeRequestTests.swift delete mode 100644 Tests/FigmaAPITests/FigmaAPIErrorTests.swift delete mode 100644 Tests/FigmaAPITests/FigmaClientErrorTests.swift delete mode 100644 Tests/FigmaAPITests/FileMetadataEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/Fixtures/ComponentsResponse.json delete mode 100644 Tests/FigmaAPITests/Fixtures/FileMetadataResponse.json delete mode 100644 Tests/FigmaAPITests/Fixtures/ImageResponse.json delete mode 100644 Tests/FigmaAPITests/Fixtures/NodesResponse.json delete mode 100644 Tests/FigmaAPITests/Fixtures/StylesResponse.json delete mode 100644 Tests/FigmaAPITests/Fixtures/VariablesResponse.json delete mode 100644 Tests/FigmaAPITests/Helpers/FixtureLoader.swift delete mode 100644 Tests/FigmaAPITests/ImageEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/MockClientTests.swift delete mode 100644 Tests/FigmaAPITests/Mocks/MockClient.swift delete mode 100644 Tests/FigmaAPITests/NodeHashablePropertiesTests.swift delete mode 100644 Tests/FigmaAPITests/NodeTests.swift delete mode 100644 Tests/FigmaAPITests/NodesEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/RateLimitedClientTests.swift delete mode 100644 Tests/FigmaAPITests/RedirectGuardDelegateTests.swift delete mode 100644 Tests/FigmaAPITests/RetryPolicyTests.swift delete mode 100644 Tests/FigmaAPITests/SharedRateLimiterTests.swift delete mode 100644 Tests/FigmaAPITests/StylesEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/UpdateVariablesEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/VariableUpdateTests.swift delete mode 100644 Tests/FigmaAPITests/VariablesEndpointTests.swift delete mode 100644 Tests/FigmaAPITests/VariablesTests.swift diff --git a/.claude/rules/testing-workflow.md b/.claude/rules/testing-workflow.md index 06f1ee61..bc483a95 100644 --- a/.claude/rules/testing-workflow.md +++ b/.claude/rules/testing-workflow.md @@ -18,7 +18,6 @@ Test targets mirror source modules: | `XcodeExportTests` | iOS export output | | `AndroidExportTests` | Android export output | | `FlutterExportTests` | Flutter export output | -| `FigmaAPITests` | API client, endpoints | | `SVGKitTests` | SVG parsing, code generation | Run specific tests: diff --git a/CLAUDE.md b/CLAUDE.md index de00150f..967507d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,14 +97,13 @@ pkl eval --format json # Package URI requires published package ## Architecture -Thirteen modules in `Sources/`: +Twelve modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | | `ExFigCLI` | CLI commands, loaders, file I/O, terminal UI | | `ExFigCore` | Domain models (Color, Image, TextStyle), processors | | `ExFigConfig` | PKL config parsing, evaluation, type bridging | -| `FigmaAPI` | Figma REST API client, endpoints, response models | | `ExFig-iOS` | iOS platform plugin (ColorsExporter, IconsExporter, etc.) | | `ExFig-Android` | Android platform plugin | | `ExFig-Flutter` | Flutter platform plugin | @@ -115,7 +114,7 @@ Thirteen modules in `Sources/`: | `WebExport` | Web/React export (CSS variables, JSX icons) | | `JinjaSupport` | Shared Jinja2 template rendering across Export modules | -**Data flow:** CLI -> PKL config parsing -> FigmaAPI fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write +**Data flow:** CLI -> PKL config parsing -> FigmaAPI (external) fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write **Alt data flow (tokens):** CLI -> local .tokens.json file -> TokensFileSource -> ExFigCore models -> W3C JSON export **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. @@ -258,7 +257,7 @@ See `ExFigCLI/CLAUDE.md` (Adding a New Subcommand). ### Adding a Figma API Endpoint -See `FigmaAPI/CLAUDE.md`. +FigmaAPI is now an external package (`swift-figma-api`). See its repository for endpoint patterns. ### Adding a Platform Plugin Exporter @@ -326,6 +325,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | libpng | 1.6.45+ | PNG decoding | | swift-custom-dump | 1.3.0+ | Test assertions | | Noora | 0.54.0+ | Terminal UI design system | +| swift-figma-api | 0.1.0+ | Figma REST API client (async/await, rate limiting) | | swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | | swift-resvg | 0.45.1 | SVG parsing/rendering | | swift-docc-plugin | 1.4.5+ | DocC documentation | diff --git a/Package.resolved b/Package.resolved index febda9fb..c8c8b13a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "50c42a046c7b934c9045956a4176324272debf4d406c8a184df7d8ff640c4239", + "originHash" : "6b7774cfa9a017b6fe0aaa93a3693582d99a724e84ffdd727c029b1bc2c83725", "pins" : [ { "identity" : "aexml", @@ -136,6 +136,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-figma-api", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DesignPipe/swift-figma-api.git", + "state" : { + "revision" : "604b29d39dcc160c8e00ea5e1d076abd371d681f", + "version" : "0.1.0" + } + }, { "identity" : "swift-jinja", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index f2e5dcc3..b9d8a1fe 100644 --- a/Package.swift +++ b/Package.swift @@ -27,13 +27,14 @@ let package = Package( .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), + .package(url: "https://github.com/DesignPipe/swift-figma-api.git", from: "0.1.0"), ], targets: [ // Main target .executableTarget( name: "ExFigCLI", dependencies: [ - "FigmaAPI", + .product(name: "FigmaAPI", package: "swift-figma-api"), "ExFigCore", "ExFigConfig", "XcodeExport", @@ -79,15 +80,6 @@ let package = Package( exclude: ["CLAUDE.md", "AGENTS.md"] ), - // Loads data via Figma REST API - .target( - name: "FigmaAPI", - dependencies: [ - "ExFigCore", - ], - exclude: ["CLAUDE.md", "AGENTS.md"] - ), - // Shared Jinja template rendering utilities .target( name: "JinjaSupport", @@ -200,21 +192,11 @@ let package = Package( // MARK: - Tests - .testTarget( - name: "FigmaAPITests", - dependencies: [ - "FigmaAPI", - .product(name: "CustomDump", package: "swift-custom-dump"), - ], - resources: [ - .copy("Fixtures/"), - ] - ), .testTarget( name: "ExFigTests", dependencies: [ "ExFigCLI", - "FigmaAPI", + .product(name: "FigmaAPI", package: "swift-figma-api"), "ExFig-Flutter", "ExFig-Web", .product(name: "CustomDump", package: "swift-custom-dump"), diff --git a/Sources/FigmaAPI/AGENTS.md b/Sources/FigmaAPI/AGENTS.md deleted file mode 120000 index 681311eb..00000000 --- a/Sources/FigmaAPI/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/Sources/FigmaAPI/CLAUDE.md b/Sources/FigmaAPI/CLAUDE.md deleted file mode 100644 index fb6c9deb..00000000 --- a/Sources/FigmaAPI/CLAUDE.md +++ /dev/null @@ -1,120 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Module Overview - -FigmaAPI is the Figma REST API client layer. It handles HTTP communication, JSON decoding, rate limiting, and retry logic. The module has a single dependency on ExFigCore (for `JSONCodec` used in response decoding). - -**Key constraint:** ExFigCore does NOT import FigmaAPI (see root CLAUDE.md "Module Boundaries"). - -## Architecture - -### Client Chain (decorator pattern) - -``` -FigmaClient (auth + base URL) - └─ wrapped by RateLimitedClient (retry + rate limiting) - └─ uses SharedRateLimiter (token bucket + fair round-robin) -``` - -- `Client` — protocol with single method: `request(_ endpoint: T) async throws -> T.Content` -- `BaseClient` — URLSession-based implementation, extracts `Retry-After` header on HTTP errors, throws `HTTPError` -- `FigmaClient` — subclass of `BaseClient`, sets `X-Figma-Token` header and base URL `https://api.figma.com/v1/` -- `GitHubClient` — subclass of `BaseClient` for GitHub API (used for version check only) -- `RateLimitedClient` — wraps any `Client`, adds exponential backoff retry via `RetryPolicy` and token-bucket rate limiting via `SharedRateLimiter` - -### Endpoint Pattern - -All endpoints conform to `Endpoint` protocol (two methods: `makeRequest(baseURL:)` and `content(from:with:)`). - -Concrete endpoints use `BaseEndpoint` refinement which: - -1. Adds `Root` associated type for response wrappers (e.g., `ComponentsResponse` wraps `[Component]`) -2. Decodes JSON via `JSONCodec.decode` (from ExFigCore, uses swift-yyjson) -3. Falls back to decoding `FigmaClientError` on decode failure (extracts Figma's error message) - -To add a new endpoint: - -1. Create struct conforming to `BaseEndpoint` in `Endpoint/` -2. Set `Content` typealias to your desired return type -3. If API response wraps content, add `Root` typealias and implement `content(from root:) -> Content` -4. Implement `makeRequest(baseURL:)` — build URL path and query items - -### CodingKeys Convention - -Figma API uses `snake_case`. Models use explicit `CodingKeys` enums for mapping (not `keyDecodingStrategy`), because `JSONCodec` (yyjson) does not support `keyDecodingStrategy`. - -Exception: `ContainingFrame` uses default Codable (camelCase property names match JSON keys `nodeId`, `pageName` — Figma returns camelCase for this specific type). - -### Error Handling - -``` -HTTPError (raw status + retryAfter + body) - └─ converted to FigmaAPIError (user-friendly messages + recovery suggestions) -FigmaClientError (Figma's own error JSON: {"status": 404, "err": "Not found"}) -``` - -`RateLimitedClient.convertToFigmaAPIError()` maps `HTTPError` and `URLError` into `FigmaAPIError`. The `FigmaAPIError.errorDescription` provides human-readable messages for common status codes (401, 403, 404, 429, 5xx) and network errors. - -### Rate Limiting - -`SharedRateLimiter` is an actor implementing token-bucket with fair round-robin across configs: - -- Default: 10 req/min (conservative Tier 1 for Starter plans) -- Burst capacity: 3 tokens -- On 429: global pause for all configs using `Retry-After` (or 60s default) -- `ConfigID` tracks per-config request counts for fair scheduling in batch mode - -### Retry Policy - -`RetryPolicy` — exponential backoff with jitter: - -- Defaults: 4 retries, 3s base delay, 30s max delay, 0.2 jitter -- Retryable HTTP codes: 429, 500, 502, 503, 504 -- Retryable URL errors: timeout, connection lost, DNS failure, etc. -- For 429: prefers `Retry-After` header over calculated delay - -## Endpoints Summary - -| Endpoint | Figma API Path | Content Type | Notes | -| ------------------------- | ------------------------------- | ------------------------- | ------------------------- | -| `ComponentsEndpoint` | `GET files/:id/components` | `[Component]` | Unwraps `meta.components` | -| `NodesEndpoint` | `GET files/:id/nodes?ids=` | `[NodeId: Node]` | Batch node fetch | -| `ImageEndpoint` | `GET images/:id?ids=&format=` | `[NodeId: ImagePath?]` | SVG/PNG/PDF export URLs | -| `StylesEndpoint` | `GET files/:id/styles` | `[Style]` | Unwraps `meta.styles` | -| `VariablesEndpoint` | `GET files/:id/variables/local` | `VariablesMeta` | Collections + values | -| `UpdateVariablesEndpoint` | `POST files/:id/variables` | `UpdateVariablesResponse` | codeSyntax updates | -| `FileMetadataEndpoint` | `GET files/:id?depth=1` | `FileMetadata` | Lightweight version check | -| `LatestReleaseEndpoint` | `GET repos/.../releases/latest` | `LatestReleaseResponse` | GitHub, not Figma | - -## Model Layer - -### Node tree (`Node.swift`) - -`NodesResponse` → `Node` → `Document` (recursive via `children`). `Document` contains fills, strokes, effects, opacity, style (typography), blend mode. All Figma enum values use `SCREAMING_CASE` raw values. - -### Change detection (`NodeHashableProperties.swift`, `FloatNormalization.swift`) - -`Document.toHashableProperties()` creates stable hashable snapshots: - -- Float normalization to 6 decimal places (`Double.normalized`) to handle Figma API precision drift -- Children sorted by name for order-independent hashing - -### Variables (`Variables.swift`, `VariableUpdate.swift`) - -`ValuesByMode` is a tagged union decoded via try-chain: `VariableAlias` → `PaintColor` → `String` → `Double` → `Bool`. - -`VariableValue.deletedButReferenced: Bool?` — Figma marks deleted-but-still-referenced variables. Filter in loaders with `guard meta.deletedButReferenced != true`. - -## Testing - -Tests use `MockClient` (thread-safe via DispatchQueue) and JSON fixtures in `Tests/FigmaAPITests/Fixtures/`. - -```bash -./bin/mise run test:filter FigmaAPITests -``` - -`MockClient` allows setting responses/errors per endpoint type, tracking request logs, and verifying parallel execution timing via `requestsStartedWithin(seconds:)`. - -`FixtureLoader` loads JSON from `Bundle.module` — use `JSONDecoder()` (not `JSONCodec`) in fixture loader since fixtures may use default key strategies. diff --git a/Sources/FigmaAPI/Client.swift b/Sources/FigmaAPI/Client.swift deleted file mode 100644 index d80d36d9..00000000 --- a/Sources/FigmaAPI/Client.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public typealias APIResult = Swift.Result - -public protocol Client: Sendable { - func request(_ endpoint: T) async throws -> T.Content -} - -/// HTTP error with status code and headers for rate limit handling. -public struct HTTPError: Error, Sendable { - public let statusCode: Int - public let retryAfter: TimeInterval? - public let body: Data - - public var localizedDescription: String { - "HTTP \(statusCode)" - } -} - -public class BaseClient: Client, @unchecked Sendable { - private let baseURL: URL - private let session: URLSession - private let redirectGuard: RedirectGuardDelegate - - public init(baseURL: URL, config: URLSessionConfiguration) { - self.baseURL = baseURL - redirectGuard = RedirectGuardDelegate() - session = URLSession(configuration: config, delegate: redirectGuard, delegateQueue: nil) - } - - public func request(_ endpoint: T) async throws -> T.Content { - let request = try endpoint.makeRequest(baseURL: baseURL) - let (data, response) = try await session.data(for: request) - - // Check for HTTP errors (especially 429 rate limit) - if let httpResponse = response as? HTTPURLResponse, - !(200 ..< 300).contains(httpResponse.statusCode) - { - let retryAfter = extractRetryAfter(from: httpResponse) - throw HTTPError( - statusCode: httpResponse.statusCode, - retryAfter: retryAfter, - body: data - ) - } - - return try endpoint.content(from: response, with: data) - } - - private func extractRetryAfter(from response: HTTPURLResponse) -> TimeInterval? { - guard let retryAfterString = response.value(forHTTPHeaderField: "Retry-After") else { - return nil - } - // Try parsing as seconds - if let seconds = Double(retryAfterString) { - return seconds - } - // Try parsing as HTTP date - let formatter = DateFormatter() - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - formatter.locale = Locale(identifier: "en_US_POSIX") - if let date = formatter.date(from: retryAfterString) { - return date.timeIntervalSinceNow - } - return nil - } -} - -// MARK: - Redirect Guard - -/// Strips sensitive authentication headers when a redirect changes the target host -/// or downgrades from HTTPS to HTTP. -/// Prevents token leakage if an API response redirects to an external domain. -/// -/// Fail-closed: if either host is nil, headers are stripped (safe default). -final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { - static let sensitiveHeaders = ["X-Figma-Token", "Authorization"] - - func urlSession( - _: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping @Sendable (URLRequest?) -> Void - ) { - var redirectRequest = request - - let originalHost = task.originalRequest?.url?.host?.lowercased() - let redirectHost = request.url?.host?.lowercased() - let originalScheme = task.originalRequest?.url?.scheme?.lowercased() - let redirectScheme = request.url?.scheme?.lowercased() - - let hostChanged = originalHost != redirectHost - let schemeDowngraded = originalScheme == "https" && redirectScheme != "https" - - // Fail-closed: nil hosts, changed host, or downgraded scheme → strip sensitive headers - if originalHost == nil || redirectHost == nil || hostChanged || schemeDowngraded { - for header in Self.sensitiveHeaders { - redirectRequest.setValue(nil, forHTTPHeaderField: header) - } - } - - completionHandler(redirectRequest) - } -} diff --git a/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift b/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift deleted file mode 100644 index 9405bec6..00000000 --- a/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift +++ /dev/null @@ -1,38 +0,0 @@ -import ExFigCore -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// Base Endpoint for application remote resource. -/// -/// Contains shared logic for all endpoints in app. -protocol BaseEndpoint: Endpoint where Content: Decodable { - /// Content wrapper. - associatedtype Root: Decodable = Content - - /// Extract content from root. - func content(from root: Root) -> Content -} - -extension BaseEndpoint where Root == Content { - func content(from root: Root) -> Content { - root - } -} - -extension BaseEndpoint { - public func content(from response: URLResponse?, with body: Data) throws -> Content { - do { - // Models use explicit CodingKeys for snake_case mapping - let resource = try JSONCodec.decode(Root.self, from: body) - return content(from: resource) - } catch let mainError { - if let error = try? JSONCodec.decode(FigmaClientError.self, from: body) { - throw error - } - - throw mainError - } - } -} diff --git a/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift b/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift deleted file mode 100644 index 8cd95d9b..00000000 --- a/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public struct ComponentsEndpoint: BaseEndpoint { - public typealias Content = [Component] - - private let fileId: String - - public init(fileId: String) { - self.fileId = fileId - } - - func content(from root: ComponentsResponse) -> [Component] { - root.meta.components - } - - public func makeRequest(baseURL: URL) -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - .appendingPathComponent("components") - return URLRequest(url: url) - } -} - -// MARK: - Response - -struct ComponentsResponse: Codable { - let meta: Meta -} - -struct Meta: Codable { - let components: [Component] -} - -public struct Component: Codable, Sendable { - public let key: String - public let nodeId: String - public let name: String - public let description: String? - public let containingFrame: ContainingFrame - - private enum CodingKeys: String, CodingKey { - case key - case nodeId = "node_id" - case name - case description - case containingFrame = "containing_frame" - } -} - -// MARK: - ContainingFrame - -public struct ContainingFrame: Codable, Sendable { - public let nodeId: String? - public let name: String? - public let pageId: String? - public let pageName: String? - public let backgroundColor: String? - public let containingComponentSet: ContainingComponentSet? -} - -// MARK: - ContainingComponentSet - -/// Represents the parent COMPONENT_SET for variant components. -/// Present when a component is a variant inside a component set. -public struct ContainingComponentSet: Codable, Sendable { - public let nodeId: String? - public let name: String? -} diff --git a/Sources/FigmaAPI/Endpoint/Endpoint.swift b/Sources/FigmaAPI/Endpoint/Endpoint.swift deleted file mode 100644 index 36345116..00000000 --- a/Sources/FigmaAPI/Endpoint/Endpoint.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// The endpoint to work with a remote content. -public protocol Endpoint { - /// Resource type. - associatedtype Content - - /// Create a new `URLRequest`. - /// - /// - Returns: Resource request. - /// - Throws: Any error creating request. - func makeRequest(baseURL: URL) throws -> URLRequest - - /// Obtain new content from response with body. - /// - /// - Parameters: - /// - response: The metadata associated with the response. - /// - body: The response body. - /// - Returns: A new endpoint content. - /// - Throws: Any error creating content. - func content(from response: URLResponse?, with body: Data) throws -> Content -} diff --git a/Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift b/Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift deleted file mode 100644 index 11b31af7..00000000 --- a/Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// Endpoint to fetch file metadata (version, lastModified) without the full document tree. -/// Uses depth=1 to minimize response size. -public struct FileMetadataEndpoint: BaseEndpoint { - public typealias Content = FileMetadata - - private let fileId: String - - public init(fileId: String) { - self.fileId = fileId - } - - public func makeRequest(baseURL: URL) throws -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - - var comps = URLComponents(url: url, resolvingAgainstBaseURL: true) - // depth=1 returns only the top-level document node, minimizing response size - comps?.queryItems = [ - URLQueryItem(name: "depth", value: "1"), - ] - guard let components = comps, let url = components.url else { - throw URLError( - .badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for FileMetadataEndpoint"] - ) - } - return URLRequest(url: url) - } -} - -// MARK: - Response Model - -/// File metadata returned by Figma API. -/// Contains version information used for change tracking. -public struct FileMetadata: Decodable, Sendable { - /// The name of the file. - public let name: String - - /// Timestamp of the last modification (ISO 8601 format). - /// Note: This updates on ANY change, including auto-saves. - public let lastModified: String - - /// Version identifier of the file. - /// Changes when the library is published or a version is manually saved. - public let version: String - - /// URL to the file's thumbnail. - public let thumbnailUrl: String? - - /// The editor type (figma or figjam). - public let editorType: String? -} diff --git a/Sources/FigmaAPI/Endpoint/ImageEndpoint.swift b/Sources/FigmaAPI/Endpoint/ImageEndpoint.swift deleted file mode 100644 index 38726b0e..00000000 --- a/Sources/FigmaAPI/Endpoint/ImageEndpoint.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public class FormatParams: Encodable { - /// A number between 0.01 and 4, the image scaling factor - public let scale: Double? - /// A string enum for the image output format, can be jpg, png, svg, or pdf - public let format: String - - /// Use the full dimensions of the node regardless of whether or not it is cropped or the space around it is empty. - /// Use this to export text nodes without cropping. Default: true. - public var useAbsoluteBounds = true - - public init(scale: Double? = nil, format: String) { - self.scale = scale - self.format = format - } - - var queryItems: [URLQueryItem] { - var items = [ - URLQueryItem(name: "format", value: format), - URLQueryItem(name: "use_absolute_bounds", value: String(useAbsoluteBounds)), - ] - if let scale { - items.append(URLQueryItem(name: "scale", value: String(scale))) - } - return items - } -} - -public class SVGParams: FormatParams { - /// Whether to include id attributes for all SVG elements. Default: false. - public var svgIncludeId = false - - /// Whether to simplify inside/outside strokes and use stroke attribute if possible instead of . Default: - /// true. - public var svgSimplifyStroke = false - - public init() { - super.init(format: "svg") - } - - override var queryItems: [URLQueryItem] { - var items = super.queryItems - items.append(URLQueryItem(name: "svg_include_id", value: String(svgIncludeId))) - items.append(URLQueryItem(name: "svg_simplify_stroke", value: String(svgSimplifyStroke))) - return items - } -} - -public class PDFParams: FormatParams { - public init() { - super.init(format: "pdf") - } -} - -public class PNGParams: FormatParams { - public init(scale: Double) { - super.init(scale: scale, format: "png") - } -} - -public struct ImageEndpoint: BaseEndpoint { - public typealias Content = [NodeId: ImagePath?] - - private let nodeIds: String - private let fileId: String - private let params: FormatParams - - public init(fileId: String, nodeIds: [String], params: FormatParams) { - self.fileId = fileId - self.nodeIds = nodeIds.joined(separator: ",") - self.params = params - } - - func content(from root: ImageResponse) -> [NodeId: ImagePath?] { - root.images - } - - public func makeRequest(baseURL: URL) throws -> URLRequest { - let url = baseURL - .appendingPathComponent("images") - .appendingPathComponent(fileId) - - var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) - comps?.queryItems = params.queryItems - comps?.queryItems?.append(URLQueryItem(name: "ids", value: nodeIds)) - guard let components = comps, let url = components.url else { - throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for ImageEndpoint"]) - } - return URLRequest(url: url) - } -} - -public struct ImageResponse: Decodable { - public let images: [NodeId: ImagePath?] -} - -public typealias ImagePath = String diff --git a/Sources/FigmaAPI/Endpoint/LatestReleaseEndpoint.swift b/Sources/FigmaAPI/Endpoint/LatestReleaseEndpoint.swift deleted file mode 100644 index f28ddbd5..00000000 --- a/Sources/FigmaAPI/Endpoint/LatestReleaseEndpoint.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public struct LatestReleaseEndpoint: BaseEndpoint { - public typealias Content = LatestReleaseResponse - - public init() {} - - public func makeRequest(baseURL: URL) -> URLRequest { - let url = baseURL.appendingPathComponent("repos/DesignPipe/exfig/releases/latest") - return URLRequest(url: url) - } -} - -// MARK: - Response - -public struct LatestReleaseResponse: Decodable { - public let tagName: String -} diff --git a/Sources/FigmaAPI/Endpoint/NodesEndpoint.swift b/Sources/FigmaAPI/Endpoint/NodesEndpoint.swift deleted file mode 100644 index 2eebcf02..00000000 --- a/Sources/FigmaAPI/Endpoint/NodesEndpoint.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public struct NodesEndpoint: BaseEndpoint { - public typealias Content = [NodeId: Node] - - private let nodeIds: String - private let fileId: String - - public init(fileId: String, nodeIds: [String]) { - self.fileId = fileId - self.nodeIds = nodeIds.joined(separator: ",") - } - - func content(from root: NodesResponse) -> Content { - root.nodes - } - - public func makeRequest(baseURL: URL) throws -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - .appendingPathComponent("nodes") - - var comps = URLComponents(url: url, resolvingAgainstBaseURL: true) - comps?.queryItems = [ - URLQueryItem(name: "ids", value: nodeIds), - ] - guard let components = comps, let url = components.url else { - throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for NodesEndpoint"]) - } - return URLRequest(url: url) - } -} diff --git a/Sources/FigmaAPI/Endpoint/StylesEndpoint.swift b/Sources/FigmaAPI/Endpoint/StylesEndpoint.swift deleted file mode 100644 index e2189986..00000000 --- a/Sources/FigmaAPI/Endpoint/StylesEndpoint.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public struct StylesEndpoint: BaseEndpoint { - public typealias Content = [Style] - - private let fileId: String - - public init(fileId: String) { - self.fileId = fileId - } - - func content(from root: StylesResponse) -> Content { - root.meta.styles - } - - public func makeRequest(baseURL: URL) -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - .appendingPathComponent("styles") - return URLRequest(url: url) - } -} diff --git a/Sources/FigmaAPI/Endpoint/UpdateVariablesEndpoint.swift b/Sources/FigmaAPI/Endpoint/UpdateVariablesEndpoint.swift deleted file mode 100644 index 381b4ecd..00000000 --- a/Sources/FigmaAPI/Endpoint/UpdateVariablesEndpoint.swift +++ /dev/null @@ -1,33 +0,0 @@ -import ExFigCore -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// Endpoint for updating Figma Variables codeSyntax -/// POST /v1/files/:file_key/variables -public struct UpdateVariablesEndpoint: BaseEndpoint { - public typealias Content = UpdateVariablesResponse - - private let fileId: String - private let body: VariablesUpdateRequest - - public init(fileId: String, body: VariablesUpdateRequest) { - self.fileId = fileId - self.body = body - } - - public func makeRequest(baseURL: URL) throws -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - .appendingPathComponent("variables") - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONCodec.encode(body) - - return request - } -} diff --git a/Sources/FigmaAPI/Endpoint/VariablesEndpoint.swift b/Sources/FigmaAPI/Endpoint/VariablesEndpoint.swift deleted file mode 100644 index c14961e9..00000000 --- a/Sources/FigmaAPI/Endpoint/VariablesEndpoint.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public struct VariablesEndpoint: BaseEndpoint { - public typealias Content = VariablesMeta - - private let fileId: String - - public init(fileId: String) { - self.fileId = fileId - } - - func content(from root: VariablesResponse) -> Content { - root.meta - } - - public func makeRequest(baseURL: URL) -> URLRequest { - let url = baseURL - .appendingPathComponent("files") - .appendingPathComponent(fileId) - .appendingPathComponent("variables") - .appendingPathComponent("local") - return URLRequest(url: url) - } -} diff --git a/Sources/FigmaAPI/FigmaAPIError.swift b/Sources/FigmaAPI/FigmaAPIError.swift deleted file mode 100644 index dac5d498..00000000 --- a/Sources/FigmaAPI/FigmaAPIError.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// User-friendly error for Figma API failures. -public struct FigmaAPIError: LocalizedError, Sendable { - /// HTTP status code (or 0 for network errors). - public let statusCode: Int - - /// Retry-After value from response headers. - public let retryAfter: TimeInterval? - - /// Current retry attempt (1-based, nil if not retrying). - public let attempt: Int? - - /// Maximum retry attempts. - public let maxAttempts: Int? - - /// Underlying URL error (for network failures). - public let urlErrorCode: URLError.Code? - - /// Underlying error message for unclassified errors. - public let underlyingMessage: String? - - /// Create a Figma API error. - /// - Parameters: - /// - statusCode: HTTP status code. - /// - retryAfter: Retry-After header value. - /// - attempt: Current retry attempt (1-based). - /// - maxAttempts: Maximum retry attempts. - /// - urlErrorCode: Underlying URL error code. - /// - underlyingMessage: Message from the original error (for unclassified errors). - public init( - statusCode: Int, - retryAfter: TimeInterval? = nil, - attempt: Int? = nil, - maxAttempts: Int? = nil, - urlErrorCode: URLError.Code? = nil, - underlyingMessage: String? = nil - ) { - self.statusCode = statusCode - self.retryAfter = retryAfter - self.attempt = attempt - self.maxAttempts = maxAttempts - self.urlErrorCode = urlErrorCode - self.underlyingMessage = underlyingMessage - } - - // MARK: - LocalizedError - - public var errorDescription: String? { - // Handle network errors first - if let urlCode = urlErrorCode { - return networkErrorDescription(for: urlCode) - } - - // Handle HTTP errors - switch statusCode { - case 401: - return "Authentication failed. Check FIGMA_PERSONAL_TOKEN environment variable." - case 403: - return "Access denied. Verify you have access to this Figma file." - case 404: - return "File not found. Check the file ID in your configuration." - case 429: - let wait = retryAfter.map { String(format: "%.0f", $0) } ?? "60" - return "Rate limited by Figma API. Waiting \(wait)s..." - case 500 ... 504: - return "Figma server error (\(statusCode)). This is usually temporary." - case 0: - // Unclassified error (not HTTP, not URLError) - if let msg = underlyingMessage { - return "Figma API error: \(msg)" - } - return "Unknown network error (no HTTP response received)" - default: - return "Figma API error: HTTP \(statusCode)" - } - } - - public var recoverySuggestion: String? { - switch statusCode { - case 401: - "Run: export FIGMA_PERSONAL_TOKEN=your_token" - case 403: - "Ensure you have view access to the Figma file" - case 404: - "Double-check the file ID in your config file" - case 429: - "Try again later or reduce batch size with --rate-limit" - case 500 ... 504: - "Check https://status.figma.com or retry in a few minutes" - default: - nil - } - } - - /// Message for retry attempts (e.g., "Retrying in 2s... (attempt 2/4)"). - public var retryMessage: String? { - guard let attempt, let maxAttempts else { - return nil - } - if let delay = retryAfter { - return "Retrying in \(Int(delay))s... (attempt \(attempt)/\(maxAttempts))" - } - return "Retrying... (attempt \(attempt)/\(maxAttempts))" - } - - // MARK: - Factory Methods - - /// Create FigmaAPIError from HTTPError. - /// - Parameters: - /// - httpError: The HTTP error to convert. - /// - attempt: Current retry attempt (1-based). - /// - maxAttempts: Maximum retry attempts. - /// - Returns: A user-friendly FigmaAPIError. - public static func from( - _ httpError: HTTPError, - attempt: Int? = nil, - maxAttempts: Int? = nil - ) -> FigmaAPIError { - FigmaAPIError( - statusCode: httpError.statusCode, - retryAfter: httpError.retryAfter, - attempt: attempt, - maxAttempts: maxAttempts - ) - } - - /// Create FigmaAPIError from URLError. - /// - Parameters: - /// - urlError: The URL error to convert. - /// - attempt: Current retry attempt (1-based). - /// - maxAttempts: Maximum retry attempts. - /// - Returns: A user-friendly FigmaAPIError. - public static func from( - _ urlError: URLError, - attempt: Int? = nil, - maxAttempts: Int? = nil - ) -> FigmaAPIError { - FigmaAPIError( - statusCode: 0, - retryAfter: nil, - attempt: attempt, - maxAttempts: maxAttempts, - urlErrorCode: urlError.code - ) - } - - // MARK: - Private - - private func networkErrorDescription(for code: URLError.Code) -> String { - switch code { - case .timedOut: - "Request timeout. The Figma server took too long to respond." - case .networkConnectionLost: - "Network connection lost. Check your internet connection." - case .notConnectedToInternet: - "Not connected to internet. Please check your connection." - case .dnsLookupFailed: - "DNS lookup failed. Unable to resolve api.figma.com." - case .cannotConnectToHost: - "Cannot connect to Figma. The server may be down." - case .cannotFindHost: - "Cannot find Figma server. Check your network settings." - default: - "Network error: \(code.rawValue)" - } - } -} diff --git a/Sources/FigmaAPI/FigmaClient.swift b/Sources/FigmaAPI/FigmaClient.swift deleted file mode 100644 index 78ea64ca..00000000 --- a/Sources/FigmaAPI/FigmaClient.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public final class FigmaClient: BaseClient, @unchecked Sendable { - // swiftlint:disable:next force_unwrapping - private static let figmaBaseURL = URL(string: "https://api.figma.com/v1/")! - - public init(accessToken: String, timeout: TimeInterval?) { - let config = URLSessionConfiguration.ephemeral - config.httpAdditionalHeaders = ["X-Figma-Token": accessToken] - config.timeoutIntervalForRequest = timeout ?? 30 - super.init(baseURL: Self.figmaBaseURL, config: config) - } -} diff --git a/Sources/FigmaAPI/GitHubClient.swift b/Sources/FigmaAPI/GitHubClient.swift deleted file mode 100644 index 79f7f334..00000000 --- a/Sources/FigmaAPI/GitHubClient.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -public final class GitHubClient: BaseClient, @unchecked Sendable { - // swiftlint:disable:next force_unwrapping - private static let gitHubBaseURL = URL(string: "https://api.github.com/")! - - public init() { - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 10 - super.init(baseURL: Self.gitHubBaseURL, config: config) - } -} diff --git a/Sources/FigmaAPI/Model/FigmaClientError.swift b/Sources/FigmaAPI/Model/FigmaClientError.swift deleted file mode 100644 index 90f282cd..00000000 --- a/Sources/FigmaAPI/Model/FigmaClientError.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -struct FigmaClientError: Decodable, LocalizedError, Sendable { - let status: Int - let err: String - - var errorDescription: String? { - switch err { - case "Not found": - // swiftlint:disable:next line_length - "Figma file not found. Check lightFileId and darkFileId (if your project supports dark mode) in the config file. Also verify that your personal access token is valid and hasn't expired." - default: - "Figma API: \(err)" - } - } -} diff --git a/Sources/FigmaAPI/Model/FloatNormalization.swift b/Sources/FigmaAPI/Model/FloatNormalization.swift deleted file mode 100644 index 200e6375..00000000 --- a/Sources/FigmaAPI/Model/FloatNormalization.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -public extension Double { - /// Normalizes a floating-point value to 6 decimal places. - /// - /// Used for stable hashing of visual properties from Figma API. - /// Figma may return slightly different float values for the same visual - /// (e.g., 0.33333334 vs 0.33333333 for the same color component). - /// Normalizing to 6 decimal places matches SVG precision and prevents - /// false positives in change detection. - /// - /// Algorithm: Multiply by 1,000,000, round, divide by 1,000,000. - /// - /// Examples: - /// - `0.33333334.normalized` → `0.333333` - /// - `0.123456789.normalized` → `0.123457` - /// - `1.0.normalized` → `1.0` - var normalized: Double { - (self * 1_000_000).rounded() / 1_000_000 - } -} diff --git a/Sources/FigmaAPI/Model/Node.swift b/Sources/FigmaAPI/Model/Node.swift deleted file mode 100644 index 1e167beb..00000000 --- a/Sources/FigmaAPI/Model/Node.swift +++ /dev/null @@ -1,272 +0,0 @@ -// -// Node.swift -// FigmaColorExporter -// -// Created by Daniil Subbotin on 28.03.2020. -// Copyright © 2020 Daniil Subbotin. All rights reserved. -// - -public typealias NodeId = String - -public struct NodesResponse: Decodable, Sendable { - public let nodes: [NodeId: Node] -} - -public struct Node: Decodable, Sendable { - public let document: Document -} - -public enum LineHeightUnit: String, Decodable, Sendable { - case pixels = "PIXELS" - case fontSize = "FONT_SIZE_%" - case intrinsic = "INTRINSIC_%" -} - -public struct TypeStyle: Decodable, Sendable { - public var fontFamily: String? - public var fontPostScriptName: String? - public var fontWeight: Double - public var fontSize: Double - public var lineHeightPx: Double - public var letterSpacing: Double - public var lineHeightUnit: LineHeightUnit - public var textCase: TextCase? -} - -public enum TextCase: String, Decodable, Sendable { - case original = "ORIGINAL" - case upper = "UPPER" - case lower = "LOWER" - case title = "TITLE" - case smallCaps = "SMALL_CAPS" - case smallCapsForced = "SMALL_CAPS_FORCED" -} - -public struct Document: Decodable, Sendable { - public let id: String - public let name: String - public let type: String? - public let fills: [Paint] - public let strokes: [Paint]? - public let strokeWeight: Double? - public let strokeAlign: StrokeAlign? - public let strokeJoin: StrokeJoin? - public let strokeCap: StrokeCap? - public let effects: [Effect]? - public let opacity: Double? - public let blendMode: BlendMode? - public let clipsContent: Bool? - public let rotation: Double? - public let children: [Document]? - public let style: TypeStyle? -} - -// MARK: - Stroke Enums - -public enum StrokeAlign: String, Decodable, Sendable { - case inside = "INSIDE" - case outside = "OUTSIDE" - case center = "CENTER" -} - -public enum StrokeJoin: String, Decodable, Sendable { - case miter = "MITER" - case bevel = "BEVEL" - case round = "ROUND" -} - -public enum StrokeCap: String, Decodable, Sendable { - case none = "NONE" - case round = "ROUND" - case square = "SQUARE" - case lineArrow = "LINE_ARROW" - case triangleArrow = "TRIANGLE_ARROW" -} - -// MARK: - Effect - -public struct Effect: Decodable, Sendable { - public let type: EffectType - public let visible: Bool? - public let radius: Double? - public let color: PaintColor? - public let offset: Vector? - public let spread: Double? - public let blendMode: BlendMode? -} - -public enum EffectType: String, Decodable, Sendable { - case innerShadow = "INNER_SHADOW" - case dropShadow = "DROP_SHADOW" - case layerBlur = "LAYER_BLUR" - case backgroundBlur = "BACKGROUND_BLUR" -} - -// MARK: - Vector - -public struct Vector: Decodable, Sendable { - // swiftlint:disable:next identifier_name - public let x, y: Double -} - -// MARK: - Blend Mode - -public enum BlendMode: String, Decodable, Sendable { - case passThrough = "PASS_THROUGH" - case normal = "NORMAL" - case darken = "DARKEN" - case multiply = "MULTIPLY" - case linearBurn = "LINEAR_BURN" - case colorBurn = "COLOR_BURN" - case lighten = "LIGHTEN" - case screen = "SCREEN" - case linearDodge = "LINEAR_DODGE" - case colorDodge = "COLOR_DODGE" - case overlay = "OVERLAY" - case softLight = "SOFT_LIGHT" - case hardLight = "HARD_LIGHT" - case difference = "DIFFERENCE" - case exclusion = "EXCLUSION" - case hue = "HUE" - case saturation = "SATURATION" - case color = "COLOR" - case luminosity = "LUMINOSITY" -} - -/// https://www.figma.com/plugin-docs/api/Paint/ -public struct Paint: Decodable, Sendable { - public let type: PaintType - public let blendMode: BlendMode? - public let opacity: Double? - public let color: PaintColor? - public let gradientStops: [GradientStop]? - - public var asSolid: SolidPaint? { - SolidPaint(self) - } -} - -// MARK: - Gradient Stop - -public struct GradientStop: Decodable, Sendable { - public let position: Double - public let color: PaintColor -} - -public enum PaintType: String, Decodable, Sendable { - case solid = "SOLID" - case image = "IMAGE" - case rectangle = "RECTANGLE" - case gradientLinear = "GRADIENT_LINEAR" - case gradientRadial = "GRADIENT_RADIAL" - case gradientAngular = "GRADIENT_ANGULAR" - case gradientDiamond = "GRADIENT_DIAMOND" -} - -public struct SolidPaint: Decodable, Sendable { - public let opacity: Double? - public let color: PaintColor - - public init?(_ paint: Paint) { - guard paint.type == .solid else { return nil } - guard let color = paint.color else { return nil } - opacity = paint.opacity - self.color = color - } -} - -public struct PaintColor: Codable, Sendable { - // swiftlint:disable:next identifier_name - /// Channel value, between 0 and 1 - public let r, g, b, a: Double -} - -// MARK: - Document → NodeHashableProperties Conversion - -public extension Document { - /// Converts the document to a hashable properties struct for change detection. - /// Float values are normalized to 6 decimal places to handle Figma API precision drift. - /// Children are sorted by name for stable hashing regardless of Figma API order. - func toHashableProperties() -> NodeHashableProperties { - // Sort children by name for stable hash (Figma API may return in different order) - let sortedChildren = children? - .map { $0.toHashableProperties() } - .sorted { $0.name < $1.name } - - return NodeHashableProperties( - name: name, - type: type ?? "UNKNOWN", - fills: fills.map { $0.toHashablePaint() }, - strokes: strokes?.map { $0.toHashablePaint() }, - strokeWeight: strokeWeight?.normalized, - strokeAlign: strokeAlign?.rawValue, - strokeJoin: strokeJoin?.rawValue, - strokeCap: strokeCap?.rawValue, - effects: effects?.map { $0.toHashableEffect() }, - opacity: opacity?.normalized, - blendMode: blendMode?.rawValue, - clipsContent: clipsContent, - rotation: rotation?.normalized, - children: sortedChildren - ) - } -} - -public extension Paint { - /// Converts the paint to a hashable paint struct. - func toHashablePaint() -> HashablePaint { - HashablePaint( - type: type.rawValue, - blendMode: blendMode?.rawValue, - color: color?.toHashableColor(), - opacity: opacity?.normalized, - gradientStops: gradientStops?.map { $0.toHashableGradientStop() } - ) - } -} - -public extension PaintColor { - /// Converts the color to a hashable color struct with normalized values. - func toHashableColor() -> HashableColor { - HashableColor( - r: r.normalized, - g: g.normalized, - b: b.normalized, - a: a.normalized - ) - } -} - -public extension GradientStop { - /// Converts the gradient stop to a hashable gradient stop struct. - func toHashableGradientStop() -> HashableGradientStop { - HashableGradientStop( - color: color.toHashableColor(), - position: position.normalized - ) - } -} - -public extension Effect { - /// Converts the effect to a hashable effect struct. - func toHashableEffect() -> HashableEffect { - HashableEffect( - type: type.rawValue, - radius: radius?.normalized, - spread: spread?.normalized, - offset: offset?.toHashableVector(), - color: color?.toHashableColor(), - visible: visible - ) - } -} - -public extension Vector { - /// Converts the vector to a hashable vector struct. - func toHashableVector() -> HashableVector { - HashableVector( - x: x.normalized, - y: y.normalized - ) - } -} diff --git a/Sources/FigmaAPI/Model/NodeHashableProperties.swift b/Sources/FigmaAPI/Model/NodeHashableProperties.swift deleted file mode 100644 index 9acfb30f..00000000 --- a/Sources/FigmaAPI/Model/NodeHashableProperties.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation - -/// Hashable representation of a Figma node's visual properties. -/// -/// Contains only the properties that affect visual output, used for -/// change detection via FNV-1a hashing. Properties like `boundVariables` -/// and `absoluteBoundingBox` are excluded as they don't affect rendered output. -/// -/// Float values should be normalized to 6 decimal places before creating -/// this struct to handle Figma API precision drift. -public struct NodeHashableProperties: Encodable, Sendable { - public let name: String - public let type: String - public let fills: [HashablePaint] - public let strokes: [HashablePaint]? - public let strokeWeight: Double? - public let strokeAlign: String? - public let strokeJoin: String? - public let strokeCap: String? - public let effects: [HashableEffect]? - public let opacity: Double? - public let blendMode: String? - public let clipsContent: Bool? - public let rotation: Double? - public let children: [NodeHashableProperties]? - - public init( - name: String, - type: String, - fills: [HashablePaint], - strokes: [HashablePaint]?, - strokeWeight: Double?, - strokeAlign: String?, - strokeJoin: String?, - strokeCap: String?, - effects: [HashableEffect]?, - opacity: Double?, - blendMode: String?, - clipsContent: Bool?, - rotation: Double?, - children: [NodeHashableProperties]? - ) { - self.name = name - self.type = type - self.fills = fills - self.strokes = strokes - self.strokeWeight = strokeWeight - self.strokeAlign = strokeAlign - self.strokeJoin = strokeJoin - self.strokeCap = strokeCap - self.effects = effects - self.opacity = opacity - self.blendMode = blendMode - self.clipsContent = clipsContent - self.rotation = rotation - self.children = children - } -} - -/// Hashable representation of a paint (fill or stroke). -public struct HashablePaint: Encodable, Sendable { - public let type: String - public let blendMode: String? - public let color: HashableColor? - public let opacity: Double? - public let gradientStops: [HashableGradientStop]? - - public init( - type: String, - blendMode: String? = nil, - color: HashableColor? = nil, - opacity: Double? = nil, - gradientStops: [HashableGradientStop]? = nil - ) { - self.type = type - self.blendMode = blendMode - self.color = color - self.opacity = opacity - self.gradientStops = gradientStops - } -} - -/// Hashable representation of a color. -/// Channel values should be normalized to 6 decimal places. -public struct HashableColor: Encodable, Sendable { - // swiftlint:disable:next identifier_name - public let r, g, b, a: Double - - public init(r: Double, g: Double, b: Double, a: Double) { - self.r = r - self.g = g - self.b = b - self.a = a - } -} - -/// Hashable representation of a gradient stop. -public struct HashableGradientStop: Encodable, Sendable { - public let color: HashableColor - public let position: Double - - public init(color: HashableColor, position: Double) { - self.color = color - self.position = position - } -} - -/// Hashable representation of an effect (shadow, blur, etc.). -public struct HashableEffect: Encodable, Sendable { - public let type: String - public let radius: Double? - public let spread: Double? - public let offset: HashableVector? - public let color: HashableColor? - public let visible: Bool? - - public init( - type: String, - radius: Double? = nil, - spread: Double? = nil, - offset: HashableVector? = nil, - color: HashableColor? = nil, - visible: Bool? = nil - ) { - self.type = type - self.radius = radius - self.spread = spread - self.offset = offset - self.color = color - self.visible = visible - } -} - -/// Hashable representation of a 2D vector. -public struct HashableVector: Encodable, Sendable { - // swiftlint:disable:next identifier_name - public let x, y: Double - - public init(x: Double, y: Double) { - self.x = x - self.y = y - } -} diff --git a/Sources/FigmaAPI/Model/Style.swift b/Sources/FigmaAPI/Model/Style.swift deleted file mode 100644 index c6a4d525..00000000 --- a/Sources/FigmaAPI/Model/Style.swift +++ /dev/null @@ -1,37 +0,0 @@ -public enum StyleType: String, Decodable, Sendable { - case fill = "FILL" - case text = "TEXT" - case effect = "EFFECT" - case grid = "GRID" -} - -public struct Style: Decodable, Sendable { - public let styleType: StyleType - public let nodeId: String - public let name: String - public let description: String - - public init(styleType: StyleType, nodeId: String, name: String, description: String) { - self.styleType = styleType - self.nodeId = nodeId - self.name = name - self.description = description - } - - private enum CodingKeys: String, CodingKey { - case styleType = "style_type" - case nodeId = "node_id" - case name - case description - } -} - -public struct StylesResponse: Decodable, Sendable { - public let error: Bool - public let status: Int - public let meta: StylesResponseContents -} - -public struct StylesResponseContents: Decodable, Sendable { - public let styles: [Style] -} diff --git a/Sources/FigmaAPI/Model/VariableUpdate.swift b/Sources/FigmaAPI/Model/VariableUpdate.swift deleted file mode 100644 index 29ba1f96..00000000 --- a/Sources/FigmaAPI/Model/VariableUpdate.swift +++ /dev/null @@ -1,51 +0,0 @@ -// Models for updating Figma Variables via REST API -// POST /v1/files/:file_key/variables - -/// Request body for updating variables -public struct VariablesUpdateRequest: Codable, Sendable { - public var variables: [VariableUpdate] - - public init(variables: [VariableUpdate]) { - self.variables = variables - } -} - -/// Single variable update action -public struct VariableUpdate: Codable, Sendable { - public var action: String - public var id: String - public var codeSyntax: VariableCodeSyntax? - - public init(id: String, codeSyntax: VariableCodeSyntax) { - action = "UPDATE" - self.id = id - self.codeSyntax = codeSyntax - } -} - -/// Platform-specific code syntax for Variables -/// Shown in Figma Dev Mode for each platform -public struct VariableCodeSyntax: Codable, Sendable { - // swiftlint:disable identifier_name - public var WEB: String? - public var ANDROID: String? - public var iOS: String? - // swiftlint:enable identifier_name - - public init(iOS: String? = nil, android: String? = nil, web: String? = nil) { - self.iOS = iOS - ANDROID = android - WEB = web - } -} - -/// Response from updating variables -public struct UpdateVariablesResponse: Codable, Sendable { - public var status: Int? - public var error: Bool? - - public init(status: Int? = nil, error: Bool? = nil) { - self.status = status - self.error = error - } -} diff --git a/Sources/FigmaAPI/Model/Variables.swift b/Sources/FigmaAPI/Model/Variables.swift deleted file mode 100644 index 61bd2dec..00000000 --- a/Sources/FigmaAPI/Model/Variables.swift +++ /dev/null @@ -1,83 +0,0 @@ -public struct Mode: Codable, Sendable { - public var modeId: String - public var name: String -} - -public struct VariableCollectionValue: Codable, Sendable { - public var defaultModeId: String - public var id: String - public var name: String - public var modes: [Mode] - public var variableIds: [String] -} - -public struct VariableAlias: Codable, Sendable { - public var id: String - public var type: String -} - -public enum ValuesByMode: Codable, Sendable { - case variableAlias(VariableAlias) - case color(PaintColor) - case string(String) - case number(Double) - case boolean(Bool) - - public init(from decoder: Decoder) throws { - if let variableAlias = try? VariableAlias(from: decoder) { - self = .variableAlias(variableAlias) - } else if let color = try? PaintColor(from: decoder) { - self = .color(color) - } else if let string = try? String(from: decoder) { - self = .string(string) - } else if let number = try? Double(from: decoder) { - self = .number(number) - } else if let boolean = try? Bool(from: decoder) { - self = .boolean(boolean) - } else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Data didn't match any expected type." - )) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .variableAlias(alias): - try container.encode(alias) - case let .color(color): - try container.encode(color) - case let .string(str): - try container.encode(str) - case let .number(num): - try container.encode(num) - case let .boolean(bool): - try container.encode(bool) - } - } -} - -public struct VariableValue: Codable, Sendable { - public var id: String - public var name: String - public var variableCollectionId: String - public var resolvedType: String? - public var scopes: [String]? - public var valuesByMode: [String: ValuesByMode] - public var description: String - public var deletedButReferenced: Bool? -} - -public typealias VariableId = String -public typealias VariableCollectionId = String - -public struct VariablesMeta: Codable, Sendable { - public var variableCollections: [VariableCollectionId: VariableCollectionValue] - public var variables: [VariableId: VariableValue] -} - -public struct VariablesResponse: Codable, Sendable { - public let meta: VariablesMeta -} diff --git a/Sources/FigmaAPI/RateLimitedClient.swift b/Sources/FigmaAPI/RateLimitedClient.swift deleted file mode 100644 index b91ffccb..00000000 --- a/Sources/FigmaAPI/RateLimitedClient.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// Callback type for retry events. -public typealias RetryCallback = @Sendable (Int, Error) async -> Void - -/// A client wrapper that applies rate limiting and retry logic to all requests. -/// -/// This client wraps another `Client` and uses a `SharedRateLimiter` -/// to coordinate request rates across multiple concurrent users. -/// It also implements exponential backoff retry for transient errors. -public final class RateLimitedClient: Client, @unchecked Sendable { - private let client: Client - private let rateLimiter: SharedRateLimiter - private let configID: ConfigID - private let retryPolicy: RetryPolicy - private let onRetry: RetryCallback? - - /// Create a rate-limited client with retry support. - /// - Parameters: - /// - client: The underlying client to wrap. - /// - rateLimiter: Shared rate limiter for coordination. - /// - configID: Identifier for this client's config. - /// - retryPolicy: Policy for retry attempts (default: standard policy). - /// - onRetry: Optional callback invoked before each retry attempt. - public init( - client: Client, - rateLimiter: SharedRateLimiter, - configID: ConfigID, - retryPolicy: RetryPolicy = RetryPolicy(), - onRetry: RetryCallback? = nil - ) { - self.client = client - self.rateLimiter = rateLimiter - self.configID = configID - self.retryPolicy = retryPolicy - self.onRetry = onRetry - } - - public func request(_ endpoint: T) async throws -> T.Content { - var lastError: Error? - - for attempt in 0 ... retryPolicy.maxRetries { - // Acquire rate limit token before making request - await rateLimiter.acquire(for: configID) - - do { - return try await client.request(endpoint) - } catch { - lastError = error - - // Handle 429 rate limit specially - always report to rate limiter - if let httpError = error as? HTTPError, httpError.statusCode == 429 { - await rateLimiter.reportRateLimit(retryAfter: httpError.retryAfter) - } - - // Check if we should retry - guard retryPolicy.shouldRetry(attempt: attempt, error: error) else { - throw convertToFigmaAPIError(error) - } - - // Calculate delay - let delay = retryPolicy.delay(for: attempt, error: error) - - // Notify retry callback - if let onRetry { - await onRetry(attempt + 1, error) - } - - // Wait before retry - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - - // Clear rate limiter pause if it was a 429 - if let httpError = error as? HTTPError, httpError.statusCode == 429 { - await rateLimiter.clearPause() - } - } - } - - // All retries exhausted - throw convertToFigmaAPIError( - lastError ?? HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - ) - } - - // MARK: - Private - - private func convertToFigmaAPIError(_ error: Error) -> FigmaAPIError { - if let httpError = error as? HTTPError { - return FigmaAPIError.from(httpError) - } - if let urlError = error as? URLError { - return FigmaAPIError.from(urlError) - } - // For other errors, preserve the original error message - // Use bestDescription to get proper error message from CustomStringConvertible - // (e.g., YYJSONError which doesn't implement LocalizedError) - return FigmaAPIError( - statusCode: 0, - underlyingMessage: error.bestDescription - ) - } -} diff --git a/Sources/FigmaAPI/RetryPolicy.swift b/Sources/FigmaAPI/RetryPolicy.swift deleted file mode 100644 index 555be120..00000000 --- a/Sources/FigmaAPI/RetryPolicy.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -#if os(Linux) - import FoundationNetworking -#endif - -/// Policy for retrying failed HTTP requests with exponential backoff. -public struct RetryPolicy: Sendable { - /// Maximum number of retry attempts. - public let maxRetries: Int - - /// Base delay for exponential backoff (in seconds). - public let baseDelay: TimeInterval - - /// Maximum delay between retries (in seconds). - public let maxDelay: TimeInterval - - /// Jitter factor (0.0-1.0) to randomize delays. - public let jitterFactor: Double - - /// HTTP status codes that are considered retryable. - private static let retryableStatusCodes: Set = [429, 500, 502, 503, 504] - - /// URL error codes that are considered retryable (transient network issues). - private static let retryableURLErrorCodes: Set = [ - .timedOut, - .networkConnectionLost, - .notConnectedToInternet, - .dnsLookupFailed, - .cannotConnectToHost, - .cannotFindHost, - .internationalRoamingOff, - .dataNotAllowed, - ] - - /// Create a retry policy with default values. - /// - Parameters: - /// - maxRetries: Maximum retry attempts (default: 4). - /// - baseDelay: Base delay in seconds (default: 3.0). - /// - maxDelay: Maximum delay in seconds (default: 30.0). - /// - jitterFactor: Jitter factor 0.0-1.0 (default: 0.2). - public init( - maxRetries: Int = 4, - baseDelay: TimeInterval = 3.0, - maxDelay: TimeInterval = 30.0, - jitterFactor: Double = 0.2 - ) { - self.maxRetries = maxRetries - self.baseDelay = baseDelay - self.maxDelay = maxDelay - self.jitterFactor = jitterFactor - } - - /// Calculate delay for a given retry attempt using exponential backoff. - /// - Parameter attempt: The retry attempt number (0-based). - /// - Returns: Delay in seconds. - public func delay(for attempt: Int) -> TimeInterval { - let exponential = baseDelay * pow(2.0, Double(attempt)) - let capped = min(exponential, maxDelay) - let jitter = capped * jitterFactor * Double.random(in: -1 ... 1) - return capped + jitter - } - - /// Calculate delay for a given retry attempt, optionally using retryAfter from error. - /// - Parameters: - /// - attempt: The retry attempt number (0-based). - /// - error: The error that caused the retry (optional). - /// - Returns: Delay in seconds. - public func delay(for attempt: Int, error: Error?) -> TimeInterval { - // For 429 errors, prefer Retry-After header if available - if let httpError = error as? HTTPError, - httpError.statusCode == 429, - let retryAfter = httpError.retryAfter - { - return retryAfter - } - return delay(for: attempt) - } - - /// Check if an error is retryable. - /// - Parameter error: The error to check. - /// - Returns: True if the error is considered retryable. - public func isRetryable(_ error: Error) -> Bool { - if let httpError = error as? HTTPError { - return Self.retryableStatusCodes.contains(httpError.statusCode) - } - if let urlError = error as? URLError { - return Self.retryableURLErrorCodes.contains(urlError.code) - } - return false - } - - /// Check if a retry should be attempted. - /// - Parameters: - /// - attempt: The current attempt number (0-based). - /// - error: The error that occurred. - /// - Returns: True if a retry should be attempted. - public func shouldRetry(attempt: Int, error: Error) -> Bool { - guard attempt < maxRetries else { - return false - } - return isRetryable(error) - } -} diff --git a/Sources/FigmaAPI/SharedRateLimiter.swift b/Sources/FigmaAPI/SharedRateLimiter.swift deleted file mode 100644 index 9826b63d..00000000 --- a/Sources/FigmaAPI/SharedRateLimiter.swift +++ /dev/null @@ -1,246 +0,0 @@ -import Foundation - -/// Identifier for a config in batch processing. -public struct ConfigID: Hashable, Sendable { - public let value: String - - public init(_ value: String) { - self.value = value - } -} - -/// Shared rate limiter for coordinating API requests across multiple configs. -/// -/// Uses a token bucket algorithm with fair round-robin queuing to ensure -/// all configs get equal access to the rate limit budget. -/// -/// Figma API rate limits (per token): -/// - Tier 1: 10-20 req/min (Starter→Enterprise) -/// - Tier 2: 25-100 req/min -/// - Tier 3: 50-150 req/min -/// -/// See: https://developers.figma.com/docs/rest-api/rate-limits/ -public actor SharedRateLimiter { - // MARK: - Configuration - - /// Default rate limit: 10 requests per minute (conservative Tier 1 for Starter plans) - public static let defaultRequestsPerMinute: Double = 10.0 - - /// Minimum time between requests in seconds. - private let minInterval: TimeInterval - - /// Maximum tokens in bucket (burst capacity). - private let maxTokens: Double - - // MARK: - State - - /// Current available tokens. - private var tokens: Double - - /// Last time tokens were replenished. - private var lastRefillTime: Date - - /// Pending requests queue for fair scheduling. - private var pendingRequests: [ConfigID] = [] - - /// Per-config request tracking for fairness. - private var requestCounts: [ConfigID: Int] = [:] - - /// Whether a global pause is in effect (429 received). - private var pauseUntil: Date? - - /// Current retry-after value if rate limited. - private var currentRetryAfter: TimeInterval? - - // MARK: - Initialization - - /// Create a new rate limiter. - /// - Parameters: - /// - requestsPerMinute: Maximum requests per minute (default: 10). - /// - burstCapacity: Maximum burst tokens (default: 3). - public init(requestsPerMinute: Double = defaultRequestsPerMinute, burstCapacity: Double = 3.0) { - minInterval = 60.0 / requestsPerMinute - maxTokens = burstCapacity - tokens = burstCapacity - lastRefillTime = Date() - } - - // MARK: - Public API - - /// Acquire permission to make a request. - /// - /// This method blocks until a token is available and it's the caller's turn - /// in the fair queue. - /// - /// - Parameter configID: Identifier for the requesting config. - public func acquire(for configID: ConfigID) async { - // Wait if globally paused (429 response) - if let pauseTime = pauseUntil, Date() < pauseTime { - let delay = pauseTime.timeIntervalSinceNow - if delay > 0 { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - } - - // Add to fair queue - pendingRequests.append(configID) - - // Wait for our turn (fair round-robin) - while !canProceed(configID) { - try? await Task.sleep(nanoseconds: 10_000_000) // 10ms polling - } - - // Wait for token availability - await waitForToken() - - // Dequeue and track - if let index = pendingRequests.firstIndex(of: configID) { - pendingRequests.remove(at: index) - } - requestCounts[configID, default: 0] += 1 - } - - /// Report a rate limit error (429 response). - /// - /// - Parameter retryAfter: Retry-After value from response header. - public func reportRateLimit(retryAfter: TimeInterval?) { - let pauseDuration = retryAfter ?? 60.0 // Default 60s if no header - pauseUntil = Date().addingTimeInterval(pauseDuration) - currentRetryAfter = pauseDuration - // Drain tokens on rate limit - tokens = 0 - } - - /// Clear any global pause. - public func clearPause() { - pauseUntil = nil - currentRetryAfter = nil - } - - /// Get current rate limiter status. - /// - Returns: Status snapshot. - public func status() -> RateLimiterStatus { - refillTokens() - return RateLimiterStatus( - availableTokens: tokens, - maxTokens: maxTokens, - requestsPerMinute: 60.0 / minInterval, - isPaused: pauseUntil != nil && Date() < (pauseUntil ?? .distantPast), - retryAfter: currentRetryAfter, - pendingRequestCount: pendingRequests.count, - configRequestCounts: requestCounts - ) - } - - /// Reset statistics for a config (when batch starts). - /// - Parameter configID: Config to reset. - public func resetStats(for configID: ConfigID) { - requestCounts[configID] = nil - } - - /// Reset all statistics. - public func resetAllStats() { - requestCounts.removeAll() - pendingRequests.removeAll() - pauseUntil = nil - currentRetryAfter = nil - } - - // MARK: - Private Methods - - /// Check if a config can proceed (fair scheduling). - private func canProceed(_ configID: ConfigID) -> Bool { - guard let firstInQueue = pendingRequests.first else { - return false - } - - // Direct match - it's our turn - if firstInQueue == configID { - return true - } - - // Fair round-robin: if we have fewer requests than others, we can proceed - let ourCount = requestCounts[configID, default: 0] - let minCount = requestCounts.values.min() ?? 0 - - // Allow if we're at or below the minimum count and first in our "tier" - if ourCount <= minCount { - // Find first request at our count level - for pending in pendingRequests { - let pendingCount = requestCounts[pending, default: 0] - if pendingCount <= minCount { - return pending == configID - } - } - } - - return false - } - - /// Wait until a token is available. - private func waitForToken() async { - while true { - refillTokens() - - if tokens >= 1.0 { - tokens -= 1.0 - return - } - - // Calculate wait time for next token - let tokensNeeded = 1.0 - tokens - let waitTime = tokensNeeded * minInterval - try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) - } - } - - /// Refill tokens based on elapsed time. - private func refillTokens() { - let now = Date() - let elapsed = now.timeIntervalSince(lastRefillTime) - let tokensToAdd = elapsed / minInterval - - tokens = min(maxTokens, tokens + tokensToAdd) - lastRefillTime = now - } -} - -// MARK: - Status Model - -/// Snapshot of rate limiter status. -public struct RateLimiterStatus: Sendable { - /// Currently available tokens. - public let availableTokens: Double - - /// Maximum token capacity. - public let maxTokens: Double - - /// Configured requests per minute. - public let requestsPerMinute: Double - - /// Whether requests are paused due to 429. - public let isPaused: Bool - - /// Current retry-after value if paused. - public let retryAfter: TimeInterval? - - /// Number of pending requests in queue. - public let pendingRequestCount: Int - - /// Per-config request counts. - public let configRequestCounts: [ConfigID: Int] - - /// Current effective request rate (requests/second). - public var currentRate: Double { - requestsPerMinute / 60.0 - } - - /// Human-readable status. - public var description: String { - if isPaused { - let retryStr = retryAfter.map { String(format: "%.0fs", $0) } ?? "unknown" - return "Paused (retry after \(retryStr))" - } - return String(format: "%.1f/%.1f tokens, %.1f req/min", availableTokens, maxTokens, requestsPerMinute) - } -} diff --git a/Tests/FigmaAPITests/ComponentsEndpointTests.swift b/Tests/FigmaAPITests/ComponentsEndpointTests.swift deleted file mode 100644 index 489cc9a7..00000000 --- a/Tests/FigmaAPITests/ComponentsEndpointTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class ComponentsEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let endpoint = ComponentsEndpoint(fileId: "abc123") - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/abc123/components" - ) - } - - // MARK: - Response Parsing - - func testContentParsesComponentsResponse() throws { - let response: ComponentsResponse = try FixtureLoader.load("ComponentsResponse") - - let endpoint = ComponentsEndpoint(fileId: "test") - let components = endpoint.content(from: response) - - XCTAssertEqual(components.count, 4) - - // Check first icon component - let arrowRight = components[0] - XCTAssertEqual(arrowRight.name, "Icons/24/arrow_right") - XCTAssertEqual(arrowRight.nodeId, "10:1") - XCTAssertEqual(arrowRight.description, "Arrow pointing right") - XCTAssertEqual(arrowRight.containingFrame.pageName, Optional("Components")) - XCTAssertEqual(arrowRight.containingFrame.name, "Icons") - } - - func testContentParsesComponentWithEmptyDescription() throws { - let response: ComponentsResponse = try FixtureLoader.load("ComponentsResponse") - - let endpoint = ComponentsEndpoint(fileId: "test") - let components = endpoint.content(from: response) - - let arrowLeft = components[1] - XCTAssertEqual(arrowLeft.name, "Icons/24/arrow_left") - XCTAssertEqual(arrowLeft.description, "") - } - - func testContentParsesImageComponent() throws { - let response: ComponentsResponse = try FixtureLoader.load("ComponentsResponse") - - let endpoint = ComponentsEndpoint(fileId: "test") - let components = endpoint.content(from: response) - - let heroBanner = components[3] - XCTAssertEqual(heroBanner.name, "Images/hero_banner") - XCTAssertEqual(heroBanner.containingFrame.pageName, Optional("Assets")) - } - - func testContentFromResponseWithBody() throws { - let data = try FixtureLoader.loadData("ComponentsResponse") - - let endpoint = ComponentsEndpoint(fileId: "test") - let components = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(components.count, 4) - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("invalid".utf8) - let endpoint = ComponentsEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } -} diff --git a/Tests/FigmaAPITests/DocumentHashChildrenOrderTests.swift b/Tests/FigmaAPITests/DocumentHashChildrenOrderTests.swift deleted file mode 100644 index e8a47fb2..00000000 --- a/Tests/FigmaAPITests/DocumentHashChildrenOrderTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -@testable import FigmaAPI -import XCTest - -/// Tests for children order stability in Document to NodeHashableProperties conversion. -final class DocumentHashChildrenOrderTests: XCTestCase { - func testChildrenOrderDoesNotAffectHash() throws { - // Same children in different order should produce the same hash - let redFill = """ - {"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}} - """ - let greenFill = """ - {"type": "SOLID", "color": {"r": 0.0, "g": 1.0, "b": 0.0, "a": 1.0}} - """ - let blueFill = """ - {"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0}} - """ - - let jsonOrder1 = """ - { - "id": "1:2", - "name": "parent", - "type": "FRAME", - "fills": [], - "children": [ - {"id": "1:3", "name": "aaa", "type": "VECTOR", "fills": [\(redFill)]}, - {"id": "1:4", "name": "bbb", "type": "VECTOR", "fills": [\(greenFill)]}, - {"id": "1:5", "name": "ccc", "type": "VECTOR", "fills": [\(blueFill)]} - ] - } - """ - - let jsonOrder2 = """ - { - "id": "1:2", - "name": "parent", - "type": "FRAME", - "fills": [], - "children": [ - {"id": "1:5", "name": "ccc", "type": "VECTOR", "fills": [\(blueFill)]}, - {"id": "1:3", "name": "aaa", "type": "VECTOR", "fills": [\(redFill)]}, - {"id": "1:4", "name": "bbb", "type": "VECTOR", "fills": [\(greenFill)]} - ] - } - """ - - let doc1 = try JSONDecoder().decode(Document.self, from: Data(jsonOrder1.utf8)) - let doc2 = try JSONDecoder().decode(Document.self, from: Data(jsonOrder2.utf8)) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let hash1 = try encoder.encode(doc1.toHashableProperties()) - let hash2 = try encoder.encode(doc2.toHashableProperties()) - - XCTAssertEqual(hash1, hash2, "Children order should not affect hash") - } - - func testNestedChildrenOrderDoesNotAffectHash() throws { - // Nested children in different order should also produce the same hash - let fill = """ - {"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}} - """ - - let jsonOrder1 = """ - { - "id": "1:1", - "name": "root", - "type": "FRAME", - "fills": [], - "children": [ - { - "id": "1:2", - "name": "group", - "type": "GROUP", - "fills": [], - "children": [ - {"id": "1:3", "name": "aaa", "type": "VECTOR", "fills": [\(fill)]}, - {"id": "1:4", "name": "bbb", "type": "VECTOR", "fills": [\(fill)]} - ] - } - ] - } - """ - - let jsonOrder2 = """ - { - "id": "1:1", - "name": "root", - "type": "FRAME", - "fills": [], - "children": [ - { - "id": "1:2", - "name": "group", - "type": "GROUP", - "fills": [], - "children": [ - {"id": "1:4", "name": "bbb", "type": "VECTOR", "fills": [\(fill)]}, - {"id": "1:3", "name": "aaa", "type": "VECTOR", "fills": [\(fill)]} - ] - } - ] - } - """ - - let doc1 = try JSONDecoder().decode(Document.self, from: Data(jsonOrder1.utf8)) - let doc2 = try JSONDecoder().decode(Document.self, from: Data(jsonOrder2.utf8)) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let hash1 = try encoder.encode(doc1.toHashableProperties()) - let hash2 = try encoder.encode(doc2.toHashableProperties()) - - XCTAssertEqual(hash1, hash2, "Nested children order should not affect hash") - } -} diff --git a/Tests/FigmaAPITests/DocumentHashConversionTests.swift b/Tests/FigmaAPITests/DocumentHashConversionTests.swift deleted file mode 100644 index 01739344..00000000 --- a/Tests/FigmaAPITests/DocumentHashConversionTests.swift +++ /dev/null @@ -1,344 +0,0 @@ -@testable import FigmaAPI -import XCTest - -/// Tests for Document to NodeHashableProperties conversion. -final class DocumentHashConversionTests: XCTestCase { - // MARK: - Basic Conversion - - func testDocumentToHashablePropertiesConvertsAllFields() throws { - let json = """ - { - "id": "1:2", - "name": "test-icon", - "type": "COMPONENT", - "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.5, "b": 0.0, "a": 1.0}}], - "strokes": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}}], - "strokeWeight": 2.0, - "strokeAlign": "CENTER", - "strokeJoin": "ROUND", - "strokeCap": "ROUND", - "effects": [{"type": "DROP_SHADOW", "radius": 4.0, "visible": true}], - "opacity": 0.9, - "blendMode": "NORMAL", - "clipsContent": true - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.name, "test-icon") - XCTAssertEqual(hashable.type, "COMPONENT") - XCTAssertEqual(hashable.fills.count, 1) - XCTAssertEqual(hashable.fills[0].type, "SOLID") - XCTAssertEqual(hashable.strokes?.count, 1) - XCTAssertEqual(hashable.strokeWeight, 2.0) - XCTAssertEqual(hashable.strokeAlign, "CENTER") - XCTAssertEqual(hashable.strokeJoin, "ROUND") - XCTAssertEqual(hashable.strokeCap, "ROUND") - XCTAssertEqual(hashable.effects?.count, 1) - XCTAssertEqual(hashable.effects?[0].type, "DROP_SHADOW") - XCTAssertEqual(hashable.opacity, 0.9) - XCTAssertEqual(hashable.blendMode, "NORMAL") - XCTAssertEqual(hashable.clipsContent, true) - } - - // MARK: - Float Normalization - - func testDocumentToHashablePropertiesNormalizesFloats() throws { - let json = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [{"type": "SOLID", "color": {"r": 0.33333334, "g": 0.66666667, "b": 0.5, "a": 1.0}}], - "strokeWeight": 1.0000001, - "opacity": 0.9999999 - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - // Values should be normalized to 6 decimal places - XCTAssertEqual(hashable.fills[0].color?.r, 0.333333) - XCTAssertEqual(hashable.fills[0].color?.g, 0.666667) - XCTAssertEqual(hashable.strokeWeight, 1.0) - XCTAssertEqual(hashable.opacity, 1.0) - } - - // MARK: - Children - - func testDocumentToHashablePropertiesConvertsChildren() throws { - let json = """ - { - "id": "1:2", - "name": "parent", - "type": "FRAME", - "fills": [], - "children": [ - { - "id": "1:3", - "name": "child", - "type": "VECTOR", - "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}] - } - ] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.children?.count, 1) - XCTAssertEqual(hashable.children?[0].name, "child") - XCTAssertEqual(hashable.children?[0].type, "VECTOR") - XCTAssertEqual(hashable.children?[0].fills.count, 1) - } - - // MARK: - Gradients - - func testDocumentToHashablePropertiesConvertsGradientStops() throws { - let json = """ - { - "id": "1:2", - "name": "gradient-icon", - "type": "VECTOR", - "fills": [{ - "type": "GRADIENT_LINEAR", - "gradientStops": [ - {"position": 0.0, "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}, - {"position": 1.0, "color": {"r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0}} - ] - }] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.fills[0].type, "GRADIENT_LINEAR") - XCTAssertEqual(hashable.fills[0].gradientStops?.count, 2) - XCTAssertEqual(hashable.fills[0].gradientStops?[0].position, 0.0) - XCTAssertEqual(hashable.fills[0].gradientStops?[1].position, 1.0) - } - - // MARK: - Effects - - func testDocumentToHashablePropertiesConvertsEffectWithOffset() throws { - let json = """ - { - "id": "1:2", - "name": "shadow-icon", - "type": "VECTOR", - "fills": [], - "effects": [{ - "type": "DROP_SHADOW", - "radius": 4.0, - "offset": {"x": 2.0, "y": 4.0}, - "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5}, - "visible": true - }] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.effects?.count, 1) - XCTAssertEqual(hashable.effects?[0].type, "DROP_SHADOW") - XCTAssertEqual(hashable.effects?[0].radius, 4.0) - XCTAssertEqual(hashable.effects?[0].offset?.x, 2.0) - XCTAssertEqual(hashable.effects?[0].offset?.y, 4.0) - XCTAssertEqual(hashable.effects?[0].color?.a, 0.5) - XCTAssertEqual(hashable.effects?[0].visible, true) - } - - // MARK: - Minimal Document - - func testDocumentToHashablePropertiesHandlesMinimalDocument() throws { - let json = """ - { - "id": "1:2", - "name": "minimal", - "fills": [] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.name, "minimal") - XCTAssertEqual(hashable.type, "UNKNOWN") - XCTAssertTrue(hashable.fills.isEmpty) - XCTAssertNil(hashable.strokes) - XCTAssertNil(hashable.strokeWeight) - XCTAssertNil(hashable.effects) - XCTAssertNil(hashable.opacity) - XCTAssertNil(hashable.children) - } - - // MARK: - Rotation - - func testDocumentToHashablePropertiesConvertsRotation() throws { - let json = """ - { - "id": "1:2", - "name": "rotated-vector", - "type": "VECTOR", - "fills": [], - "rotation": -1.0471975434247853 - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - // Rotation should be normalized to 6 decimal places - XCTAssertEqual(hashable.rotation, -1.047198) - } - - func testRotationChangeProducesDifferentHash() throws { - let jsonNoRotation = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}] - } - """ - - let jsonWithRotation = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}], - "rotation": 1.5707963267948966 - } - """ - - let docNoRotation = try JSONDecoder().decode(Document.self, from: Data(jsonNoRotation.utf8)) - let docWithRotation = try JSONDecoder().decode(Document.self, from: Data(jsonWithRotation.utf8)) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let hashNoRotation = try encoder.encode(docNoRotation.toHashableProperties()) - let hashWithRotation = try encoder.encode(docWithRotation.toHashableProperties()) - - XCTAssertNotEqual(hashNoRotation, hashWithRotation) - } - - // MARK: - Effect Spread - - func testDocumentToHashablePropertiesConvertsEffectSpread() throws { - let json = """ - { - "id": "1:2", - "name": "shadow-icon", - "type": "VECTOR", - "fills": [], - "effects": [{ - "type": "DROP_SHADOW", - "radius": 4.0, - "spread": 2.0, - "offset": {"x": 0.0, "y": 2.0}, - "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.25}, - "visible": true - }] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.effects?[0].spread, 2.0) - } - - func testSpreadChangeProducesDifferentHash() throws { - let jsonSpread2 = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [], - "effects": [{"type": "DROP_SHADOW", "radius": 4.0, "spread": 2.0, "visible": true}] - } - """ - - let jsonSpread4 = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [], - "effects": [{"type": "DROP_SHADOW", "radius": 4.0, "spread": 4.0, "visible": true}] - } - """ - - let docSpread2 = try JSONDecoder().decode(Document.self, from: Data(jsonSpread2.utf8)) - let docSpread4 = try JSONDecoder().decode(Document.self, from: Data(jsonSpread4.utf8)) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let hashSpread2 = try encoder.encode(docSpread2.toHashableProperties()) - let hashSpread4 = try encoder.encode(docSpread4.toHashableProperties()) - - XCTAssertNotEqual(hashSpread2, hashSpread4) - } - - // MARK: - Paint BlendMode - - func testDocumentToHashablePropertiesConvertsPaintBlendMode() throws { - let json = """ - { - "id": "1:2", - "name": "blended-icon", - "type": "VECTOR", - "fills": [{ - "type": "SOLID", - "blendMode": "MULTIPLY", - "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0} - }] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - let hashable = document.toHashableProperties() - - XCTAssertEqual(hashable.fills[0].blendMode, "MULTIPLY") - } - - func testPaintBlendModeChangeProducesDifferentHash() throws { - let jsonNormal = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [{"type": "SOLID", "blendMode": "NORMAL", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}] - } - """ - - let jsonMultiply = """ - { - "id": "1:2", - "name": "icon", - "type": "VECTOR", - "fills": [{"type": "SOLID", "blendMode": "MULTIPLY", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}] - } - """ - - let docNormal = try JSONDecoder().decode(Document.self, from: Data(jsonNormal.utf8)) - let docMultiply = try JSONDecoder().decode(Document.self, from: Data(jsonMultiply.utf8)) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let hashNormal = try encoder.encode(docNormal.toHashableProperties()) - let hashMultiply = try encoder.encode(docMultiply.toHashableProperties()) - - XCTAssertNotEqual(hashNormal, hashMultiply) - } -} diff --git a/Tests/FigmaAPITests/EndpointMakeRequestTests.swift b/Tests/FigmaAPITests/EndpointMakeRequestTests.swift deleted file mode 100644 index 9c3caf52..00000000 --- a/Tests/FigmaAPITests/EndpointMakeRequestTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -@testable import FigmaAPI -import Foundation -#if os(Linux) - import FoundationNetworking -#endif -import XCTest - -/// Tests that endpoint `makeRequest` properly throws instead of crashing -/// when URLComponents cannot produce a valid URL. -final class EndpointMakeRequestTests: XCTestCase { - // MARK: - Valid Base URL (happy path) - - func testImageEndpointMakeRequestSucceeds() throws { - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1")) - let endpoint = ImageEndpoint(fileId: "abc123", nodeIds: ["1:2"], params: SVGParams()) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertNotNil(request.url) - XCTAssertTrue(request.url?.absoluteString.contains("abc123") ?? false) - } - - func testNodesEndpointMakeRequestSucceeds() throws { - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1")) - let endpoint = NodesEndpoint(fileId: "abc123", nodeIds: ["1:2", "3:4"]) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertNotNil(request.url) - XCTAssertTrue(request.url?.absoluteString.contains("nodes") ?? false) - } - - func testFileMetadataEndpointMakeRequestSucceeds() throws { - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1")) - let endpoint = FileMetadataEndpoint(fileId: "abc123") - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertNotNil(request.url) - XCTAssertTrue(request.url?.absoluteString.contains("depth=1") ?? false) - } -} diff --git a/Tests/FigmaAPITests/FigmaAPIErrorTests.swift b/Tests/FigmaAPITests/FigmaAPIErrorTests.swift deleted file mode 100644 index 678ed459..00000000 --- a/Tests/FigmaAPITests/FigmaAPIErrorTests.swift +++ /dev/null @@ -1,227 +0,0 @@ -@testable import FigmaAPI -import Foundation -import Testing - -@Suite("FigmaAPIError") -struct FigmaAPIErrorTests { - // MARK: - Error Descriptions - - @Test("401 error has authentication message") - func authenticationErrorMessage() { - let error = FigmaAPIError(statusCode: 401) - - #expect(error.errorDescription?.contains("Authentication failed") == true) - #expect(error.errorDescription?.contains("FIGMA_PERSONAL_TOKEN") == true) - } - - @Test("403 error has access denied message") - func accessDeniedErrorMessage() { - let error = FigmaAPIError(statusCode: 403) - - #expect(error.errorDescription?.contains("Access denied") == true) - #expect(error.errorDescription?.contains("access") == true) - } - - @Test("404 error has not found message") - func notFoundErrorMessage() { - let error = FigmaAPIError(statusCode: 404) - - #expect(error.errorDescription?.contains("not found") == true) - #expect(error.errorDescription?.contains("file ID") == true) - } - - @Test("429 error includes retry-after in message") - func rateLimitErrorIncludesRetryAfter() { - let error = FigmaAPIError(statusCode: 429, retryAfter: 45.0) - - #expect(error.errorDescription?.contains("Rate limited") == true) - #expect(error.errorDescription?.contains("45") == true) - } - - @Test("429 error without retry-after uses default") - func rateLimitErrorUsesDefault() { - let error = FigmaAPIError(statusCode: 429) - - #expect(error.errorDescription?.contains("Rate limited") == true) - #expect(error.errorDescription?.contains("60") == true) - } - - @Test("500 error indicates server error") - func internalServerErrorMessage() { - let error = FigmaAPIError(statusCode: 500) - - #expect(error.errorDescription?.contains("server error") == true) - #expect(error.errorDescription?.contains("500") == true) - } - - @Test("502 error indicates server error") - func badGatewayErrorMessage() { - let error = FigmaAPIError(statusCode: 502) - - #expect(error.errorDescription?.contains("server error") == true) - #expect(error.errorDescription?.contains("502") == true) - } - - @Test("503 error indicates server error") - func serviceUnavailableErrorMessage() { - let error = FigmaAPIError(statusCode: 503) - - #expect(error.errorDescription?.contains("server error") == true) - #expect(error.errorDescription?.contains("503") == true) - } - - @Test("504 error indicates server error") - func gatewayTimeoutErrorMessage() { - let error = FigmaAPIError(statusCode: 504) - - #expect(error.errorDescription?.contains("server error") == true) - #expect(error.errorDescription?.contains("504") == true) - } - - @Test("Unknown status code shows generic message") - func unknownStatusCodeGenericMessage() { - let error = FigmaAPIError(statusCode: 418) - - #expect(error.errorDescription?.contains("418") == true) - #expect(error.errorDescription?.contains("HTTP") == true) - } - - // MARK: - Recovery Suggestions - - @Test("401 error suggests setting token") - func authenticationRecoverySuggestion() { - let error = FigmaAPIError(statusCode: 401) - - #expect(error.recoverySuggestion?.contains("export FIGMA_PERSONAL_TOKEN") == true) - } - - @Test("429 error suggests trying later") - func rateLimitRecoverySuggestion() { - let error = FigmaAPIError(statusCode: 429) - - #expect(error.recoverySuggestion?.contains("later") == true) - } - - @Test("500-504 errors suggest checking status page") - func serverErrorRecoverySuggestion() { - for statusCode in [500, 501, 502, 503, 504] { - let error = FigmaAPIError(statusCode: statusCode) - #expect(error.recoverySuggestion?.contains("status.figma.com") == true) - } - } - - @Test("400 error has nil recovery suggestion") - func badRequestNoRecoverySuggestion() { - let error = FigmaAPIError(statusCode: 400) - - #expect(error.recoverySuggestion == nil) - } - - // MARK: - Retry Attempt Info - - @Test("Error includes attempt info when provided") - func errorIncludesAttemptInfo() { - let error = FigmaAPIError(statusCode: 500, attempt: 2, maxAttempts: 4) - - #expect(error.attempt == 2) - #expect(error.maxAttempts == 4) - } - - @Test("Retry message formatted correctly") - func retryMessageFormatted() { - let error = FigmaAPIError(statusCode: 502, retryAfter: 4.0, attempt: 2, maxAttempts: 4) - - let message = error.retryMessage - #expect(message?.contains("attempt 2/4") == true || message?.contains("2/4") == true) - } - - @Test("Retry message nil when no attempt info") - func retryMessageNilWithoutAttemptInfo() { - let error = FigmaAPIError(statusCode: 500) - - #expect(error.retryMessage == nil) - } - - // MARK: - LocalizedError Conformance - - @Test("Conforms to LocalizedError") - func conformsToLocalizedError() { - let error: LocalizedError = FigmaAPIError(statusCode: 500) - - #expect(error.errorDescription != nil) - } - - // MARK: - Factory Methods - - @Test("fromHTTPError creates FigmaAPIError") - func fromHTTPErrorCreatesAPIError() { - let httpError = HTTPError(statusCode: 429, retryAfter: 30.0, body: Data()) - let apiError = FigmaAPIError.from(httpError) - - #expect(apiError.statusCode == 429) - #expect(apiError.retryAfter == 30.0) - } - - @Test("fromHTTPError with attempt info") - func fromHTTPErrorWithAttemptInfo() { - let httpError = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - let apiError = FigmaAPIError.from(httpError, attempt: 3, maxAttempts: 4) - - #expect(apiError.statusCode == 500) - #expect(apiError.attempt == 3) - #expect(apiError.maxAttempts == 4) - } - - // MARK: - Network Error Handling - - @Test("fromURLError creates appropriate error") - func fromURLErrorCreatesError() { - let urlError = URLError(.timedOut) - let apiError = FigmaAPIError.from(urlError) - - #expect(apiError.errorDescription?.lowercased().contains("timeout") == true) - } - - @Test("Network connection lost message") - func networkConnectionLostMessage() { - let urlError = URLError(.networkConnectionLost) - let apiError = FigmaAPIError.from(urlError) - - #expect(apiError.errorDescription?.lowercased().contains("network") == true) - } - - @Test("Not connected to internet message") - func notConnectedToInternetMessage() { - let urlError = URLError(.notConnectedToInternet) - let apiError = FigmaAPIError.from(urlError) - - #expect(apiError.errorDescription?.lowercased().contains("internet") == true) - } - - // MARK: - Unclassified Errors (HTTP 0) - - @Test("HTTP 0 without underlying message shows generic message") - func unclassifiedErrorWithoutMessage() { - let error = FigmaAPIError(statusCode: 0) - - #expect(error.errorDescription?.contains("Unknown network error") == true) - } - - @Test("HTTP 0 with underlying message shows that message") - func unclassifiedErrorWithMessage() { - let error = FigmaAPIError(statusCode: 0, underlyingMessage: "File not found") - - #expect(error.errorDescription == "Figma API error: File not found") - } - - @Test("HTTP 0 preserves detailed error context") - func unclassifiedErrorPreservesContext() { - let error = FigmaAPIError( - statusCode: 0, - underlyingMessage: "JSON decoding failed: key 'nodes' not found" - ) - - #expect(error.errorDescription?.contains("JSON decoding failed") == true) - #expect(error.errorDescription?.contains("nodes") == true) - } -} diff --git a/Tests/FigmaAPITests/FigmaClientErrorTests.swift b/Tests/FigmaAPITests/FigmaClientErrorTests.swift deleted file mode 100644 index 671aa4e6..00000000 --- a/Tests/FigmaAPITests/FigmaClientErrorTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -@testable import FigmaAPI -import XCTest - -final class FigmaClientErrorTests: XCTestCase { - func testNotFoundError() throws { - let json = """ - {"status": 404, "err": "Not found"} - """ - // swiftlint:disable:next force_try - let error = try JSONDecoder().decode(FigmaClientError.self, from: Data(json.utf8)) - - XCTAssertEqual(error.status, 404) - XCTAssertEqual(error.err, "Not found") - XCTAssertTrue(error.errorDescription?.contains("Figma file not found") == true) - XCTAssertTrue(error.errorDescription?.contains("lightFileId") == true) - } - - func testGenericError() throws { - let json = """ - {"status": 500, "err": "Internal server error"} - """ - // swiftlint:disable:next force_try - let error = try JSONDecoder().decode(FigmaClientError.self, from: Data(json.utf8)) - - XCTAssertEqual(error.status, 500) - XCTAssertEqual(error.err, "Internal server error") - XCTAssertEqual(error.errorDescription, "Figma API: Internal server error") - } - - func testRateLimitError() throws { - let json = """ - {"status": 429, "err": "Rate limit exceeded"} - """ - // swiftlint:disable:next force_try - let error = try JSONDecoder().decode(FigmaClientError.self, from: Data(json.utf8)) - - XCTAssertEqual(error.status, 429) - XCTAssertEqual(error.errorDescription, "Figma API: Rate limit exceeded") - } -} diff --git a/Tests/FigmaAPITests/FileMetadataEndpointTests.swift b/Tests/FigmaAPITests/FileMetadataEndpointTests.swift deleted file mode 100644 index fb6facd1..00000000 --- a/Tests/FigmaAPITests/FileMetadataEndpointTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class FileMetadataEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let endpoint = FileMetadataEndpoint(fileId: "abc123") - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/abc123?depth=1" - ) - } - - func testMakeRequestIncludesDepthParameter() throws { - let endpoint = FileMetadataEndpoint(fileId: "test") - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertTrue(request.url?.query?.contains("depth=1") ?? false) - } - - // MARK: - Response Parsing - - func testContentParsesFileMetadata() throws { - let data = try FixtureLoader.loadData("FileMetadataResponse") - - let endpoint = FileMetadataEndpoint(fileId: "test") - let metadata = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(metadata.name, "Design System") - XCTAssertEqual(metadata.version, "1234567890") - XCTAssertEqual(metadata.lastModified, "2024-01-15T10:30:00Z") - XCTAssertEqual(metadata.editorType, "figma") - XCTAssertNotNil(metadata.thumbnailUrl) - } - - func testContentParsesVersionCorrectly() throws { - let data = try FixtureLoader.loadData("FileMetadataResponse") - - let endpoint = FileMetadataEndpoint(fileId: "test") - let metadata = try endpoint.content(from: nil, with: data) - - // Version should be a string identifier - XCTAssertFalse(metadata.version.isEmpty) - XCTAssertEqual(metadata.version, "1234567890") - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("invalid".utf8) - let endpoint = FileMetadataEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } - - func testContentThrowsFigmaErrorOnAPIError() { - let errorJSON = """ - { - "status": 404, - "err": "Not found" - } - """ - let errorData = Data(errorJSON.utf8) - let endpoint = FileMetadataEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: errorData)) { error in - XCTAssertTrue(error is FigmaClientError) - if let figmaError = error as? FigmaClientError { - XCTAssertEqual(figmaError.status, 404) - } - } - } -} diff --git a/Tests/FigmaAPITests/Fixtures/ComponentsResponse.json b/Tests/FigmaAPITests/Fixtures/ComponentsResponse.json deleted file mode 100644 index 8dbd9ba3..00000000 --- a/Tests/FigmaAPITests/Fixtures/ComponentsResponse.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "meta": { - "components": [ - { - "key": "icon_abc123", - "node_id": "10:1", - "name": "Icons/24/arrow_right", - "description": "Arrow pointing right", - "containing_frame": { - "nodeId": "10:0", - "name": "Icons", - "pageName": "Components" - } - }, - { - "key": "icon_def456", - "node_id": "10:2", - "name": "Icons/24/arrow_left", - "description": "", - "containing_frame": { - "nodeId": "10:0", - "name": "Icons", - "pageName": "Components" - } - }, - { - "key": "icon_ghi789", - "node_id": "10:3", - "name": "Icons/16/close", - "description": "Close button icon", - "containing_frame": { - "nodeId": "10:0", - "name": "Icons", - "pageName": "Components" - } - }, - { - "key": "img_jkl012", - "node_id": "20:1", - "name": "Images/hero_banner", - "description": "Main hero banner image", - "containing_frame": { - "nodeId": "20:0", - "name": "Images", - "pageName": "Assets" - } - } - ] - } -} diff --git a/Tests/FigmaAPITests/Fixtures/FileMetadataResponse.json b/Tests/FigmaAPITests/Fixtures/FileMetadataResponse.json deleted file mode 100644 index 7b7cc76e..00000000 --- a/Tests/FigmaAPITests/Fixtures/FileMetadataResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "Design System", - "lastModified": "2024-01-15T10:30:00Z", - "version": "1234567890", - "thumbnailUrl": "https://figma-alpha-api.s3.us-west-2.amazonaws.com/thumbnails/abc123.png", - "editorType": "figma", - "document": { - "id": "0:0", - "name": "Document", - "type": "DOCUMENT", - "children": [] - } -} diff --git a/Tests/FigmaAPITests/Fixtures/ImageResponse.json b/Tests/FigmaAPITests/Fixtures/ImageResponse.json deleted file mode 100644 index fe6b2578..00000000 --- a/Tests/FigmaAPITests/Fixtures/ImageResponse.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "images": { - "10:1": "https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/abc123-arrow_right.svg", - "10:2": "https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/def456-arrow_left.svg", - "10:3": "https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/ghi789-close.svg", - "20:1": "https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/jkl012-hero_banner.png" - } -} diff --git a/Tests/FigmaAPITests/Fixtures/NodesResponse.json b/Tests/FigmaAPITests/Fixtures/NodesResponse.json deleted file mode 100644 index 899e7b3c..00000000 --- a/Tests/FigmaAPITests/Fixtures/NodesResponse.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "nodes": { - "1:2": { - "document": { - "id": "1:2", - "name": "primary/background", - "fills": [ - { - "type": "SOLID", - "opacity": 1.0, - "color": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } - } - ], - "style": null - } - }, - "1:3": { - "document": { - "id": "1:3", - "name": "primary/text", - "fills": [ - { - "type": "SOLID", - "opacity": 1.0, - "color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } - } - ], - "style": null - } - }, - "1:4": { - "document": { - "id": "1:4", - "name": "secondary/background_dark", - "fills": [ - { - "type": "SOLID", - "opacity": 0.8, - "color": { "r": 0.2, "g": 0.2, "b": 0.2, "a": 1.0 } - } - ], - "style": null - } - }, - "2:1": { - "document": { - "id": "2:1", - "name": "heading/large", - "fills": [], - "style": { - "fontFamily": "Inter", - "fontPostScriptName": "Inter-Bold", - "fontWeight": 700, - "fontSize": 32.0, - "lineHeightPx": 40.0, - "letterSpacing": -0.5, - "lineHeightUnit": "PIXELS", - "textCase": "ORIGINAL" - } - } - }, - "2:2": { - "document": { - "id": "2:2", - "name": "body/regular", - "fills": [], - "style": { - "fontFamily": "Inter", - "fontPostScriptName": "Inter-Regular", - "fontWeight": 400, - "fontSize": 16.0, - "lineHeightPx": 24.0, - "letterSpacing": 0.0, - "lineHeightUnit": "PIXELS", - "textCase": "ORIGINAL" - } - } - } - } -} diff --git a/Tests/FigmaAPITests/Fixtures/StylesResponse.json b/Tests/FigmaAPITests/Fixtures/StylesResponse.json deleted file mode 100644 index ab27a754..00000000 --- a/Tests/FigmaAPITests/Fixtures/StylesResponse.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "error": false, - "status": 200, - "meta": { - "styles": [ - { - "key": "abc123", - "style_type": "FILL", - "node_id": "1:2", - "name": "primary/background", - "description": "" - }, - { - "key": "def456", - "style_type": "FILL", - "node_id": "1:3", - "name": "primary/text", - "description": "ios" - }, - { - "key": "ghi789", - "style_type": "FILL", - "node_id": "1:4", - "name": "secondary/background_dark", - "description": "" - }, - { - "key": "jkl012", - "style_type": "TEXT", - "node_id": "2:1", - "name": "heading/large", - "description": "" - }, - { - "key": "mno345", - "style_type": "TEXT", - "node_id": "2:2", - "name": "body/regular", - "description": "android" - } - ] - } -} diff --git a/Tests/FigmaAPITests/Fixtures/VariablesResponse.json b/Tests/FigmaAPITests/Fixtures/VariablesResponse.json deleted file mode 100644 index 4dc42b53..00000000 --- a/Tests/FigmaAPITests/Fixtures/VariablesResponse.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "meta": { - "variableCollections": { - "VariableCollectionId:1:1": { - "defaultModeId": "1:0", - "id": "VariableCollectionId:1:1", - "name": "Colors", - "modes": [ - { "modeId": "1:0", "name": "Light" }, - { "modeId": "1:1", "name": "Dark" } - ], - "variableIds": [ - "VariableID:1:2", - "VariableID:1:3" - ] - } - }, - "variables": { - "VariableID:1:2": { - "id": "VariableID:1:2", - "name": "primary/background", - "variableCollectionId": "VariableCollectionId:1:1", - "valuesByMode": { - "1:0": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 }, - "1:1": { "r": 0.1, "g": 0.1, "b": 0.1, "a": 1.0 } - }, - "description": "Main background color" - }, - "VariableID:1:3": { - "id": "VariableID:1:3", - "name": "primary/text", - "variableCollectionId": "VariableCollectionId:1:1", - "valuesByMode": { - "1:0": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 }, - "1:1": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } - }, - "description": "Primary text color" - } - } - } -} diff --git a/Tests/FigmaAPITests/Helpers/FixtureLoader.swift b/Tests/FigmaAPITests/Helpers/FixtureLoader.swift deleted file mode 100644 index 3c65dd63..00000000 --- a/Tests/FigmaAPITests/Helpers/FixtureLoader.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -/// Helper for loading JSON fixtures in tests. -enum FixtureLoader { - /// Loads JSON data from a fixture file. - /// - Parameter name: The fixture file name without extension. - /// - Returns: The raw Data from the fixture file. - static func loadData(_ name: String) throws -> Data { - let bundle = Bundle.module - guard let url = bundle.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") else { - throw FixtureError.fileNotFound(name) - } - return try Data(contentsOf: url) - } - - /// Loads and decodes a JSON fixture. - /// - Parameter name: The fixture file name without extension. - /// - Returns: The decoded object of type T. - static func load(_ name: String) throws -> T { - let data = try loadData(name) - // Models use explicit CodingKeys for snake_case mapping - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: data) - } -} - -enum FixtureError: Error, LocalizedError { - case fileNotFound(String) - - var errorDescription: String? { - switch self { - case let .fileNotFound(name): - "Fixture file not found: \(name).json" - } - } -} diff --git a/Tests/FigmaAPITests/ImageEndpointTests.swift b/Tests/FigmaAPITests/ImageEndpointTests.swift deleted file mode 100644 index 569f6030..00000000 --- a/Tests/FigmaAPITests/ImageEndpointTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class ImageEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestWithPNGParams() throws { - let params = PNGParams(scale: 2.0) - let endpoint = ImageEndpoint(fileId: "abc123", nodeIds: ["1:2", "1:3"], params: params) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - let url = request.url?.absoluteString ?? "" - - XCTAssertTrue(url.contains("images/abc123")) - XCTAssertTrue(url.contains("format=png")) - XCTAssertTrue(url.contains("scale=2.0")) - XCTAssertTrue(url.contains("ids=1:2,1:3")) - } - - func testMakeRequestWithSVGParams() throws { - let params = SVGParams() - params.svgIncludeId = true - params.svgSimplifyStroke = true - - let endpoint = ImageEndpoint(fileId: "file123", nodeIds: ["10:1"], params: params) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - let url = request.url?.absoluteString ?? "" - - XCTAssertTrue(url.contains("format=svg")) - XCTAssertTrue(url.contains("svg_include_id=true")) - XCTAssertTrue(url.contains("svg_simplify_stroke=true")) - } - - func testMakeRequestWithPDFParams() throws { - let params = PDFParams() - let endpoint = ImageEndpoint(fileId: "file123", nodeIds: ["5:1"], params: params) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - let url = request.url?.absoluteString ?? "" - - XCTAssertTrue(url.contains("format=pdf")) - XCTAssertFalse(url.contains("scale=")) - } - - func testMakeRequestIncludesUseAbsoluteBounds() throws { - let params = PNGParams(scale: 1.0) - let endpoint = ImageEndpoint(fileId: "file123", nodeIds: ["1:1"], params: params) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - let url = request.url?.absoluteString ?? "" - - XCTAssertTrue(url.contains("use_absolute_bounds=true")) - } - - // MARK: - Response Parsing - - func testContentParsesImageResponse() throws { - let response: ImageResponse = try FixtureLoader.load("ImageResponse") - - let endpoint = ImageEndpoint(fileId: "test", nodeIds: [], params: SVGParams()) - let images = endpoint.content(from: response) - - XCTAssertEqual(images.count, 4) - XCTAssertNotNil(images["10:1"] as Any?) - XCTAssertTrue(images["10:1"]??.contains("arrow_right.svg") ?? false) - } - - func testContentFromResponseWithBody() throws { - let data = try FixtureLoader.loadData("ImageResponse") - - let endpoint = ImageEndpoint(fileId: "test", nodeIds: [], params: SVGParams()) - let images = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(images.count, 4) - } - - // MARK: - FormatParams - - func testPNGParamsQueryItems() { - let params = PNGParams(scale: 3.0) - let items = params.queryItems - - XCTAssertTrue(items.contains { $0.name == "format" && $0.value == "png" }) - XCTAssertTrue(items.contains { $0.name == "scale" && $0.value == "3.0" }) - } - - func testSVGParamsDefaultValues() { - let params = SVGParams() - - XCTAssertFalse(params.svgIncludeId) - XCTAssertFalse(params.svgSimplifyStroke) - } - - func testSVGParamsQueryItems() { - let params = SVGParams() - params.svgIncludeId = true - let items = params.queryItems - - XCTAssertTrue(items.contains { $0.name == "svg_include_id" && $0.value == "true" }) - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("bad json".utf8) - let endpoint = ImageEndpoint(fileId: "test", nodeIds: [], params: SVGParams()) - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } -} diff --git a/Tests/FigmaAPITests/MockClientTests.swift b/Tests/FigmaAPITests/MockClientTests.swift deleted file mode 100644 index f3ed28e4..00000000 --- a/Tests/FigmaAPITests/MockClientTests.swift +++ /dev/null @@ -1,269 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class MockClientTests: XCTestCase { - var client: MockClient! - - override func setUp() { - super.setUp() - client = MockClient() - } - - override func tearDown() { - client = nil - super.tearDown() - } - - // MARK: - Response Configuration - - func testSetResponseReturnsConfiguredValue() async throws { - let expectedStyles = [ - Style(styleType: .fill, nodeId: "1:1", name: "test", description: ""), - ] - client.setResponse(expectedStyles, for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "test") - let result = try await client.request(endpoint) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].name, "test") - } - - func testSetResponseOverridesPreviousResponse() async throws { - let first = [Style(styleType: .fill, nodeId: "1:1", name: "first", description: "")] - let second = [Style(styleType: .fill, nodeId: "2:2", name: "second", description: "")] - - client.setResponse(first, for: StylesEndpoint.self) - client.setResponse(second, for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "test") - let result = try await client.request(endpoint) - - XCTAssertEqual(result[0].name, "second") - } - - // MARK: - Error Configuration - - func testSetErrorThrowsConfiguredError() async { - let expectedError = MockClientError.noResponseConfigured(endpoint: "test") - client.setError(expectedError, for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "test") - - do { - _ = try await client.request(endpoint) - XCTFail("Expected error to be thrown") - } catch { - XCTAssertTrue(error is MockClientError) - } - } - - func testSetResponseClearsError() async throws { - client.setError(MockClientError.noResponseConfigured(endpoint: ""), for: StylesEndpoint.self) - client.setResponse([Style](), for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "test") - let result = try await client.request(endpoint) - - XCTAssertEqual(result.count, 0) - } - - func testSetErrorClearsResponse() async { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setError(MockClientError.noResponseConfigured(endpoint: "cleared"), for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "test") - - do { - _ = try await client.request(endpoint) - XCTFail("Expected error") - } catch { - // Success - } - } - - // MARK: - Request Logging - - func testRequestLogRecordsRequests() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - - let endpoint = StylesEndpoint(fileId: "myfile") - _ = try await client.request(endpoint) - - XCTAssertEqual(client.requestCount, 1) - XCTAssertTrue(client.lastRequest?.url?.absoluteString.contains("myfile") ?? false) - } - - func testRequestLogRecordsMultipleRequests() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - - let endpoint1 = StylesEndpoint(fileId: "file1") - let endpoint2 = StylesEndpoint(fileId: "file2") - - _ = try await client.request(endpoint1) - _ = try await client.request(endpoint2) - - XCTAssertEqual(client.requestCount, 2) - } - - func testRequestsContainingPath() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setResponse([Component](), for: ComponentsEndpoint.self) - - _ = try await client.request(StylesEndpoint(fileId: "test")) - _ = try await client.request(ComponentsEndpoint(fileId: "test")) - - let styleRequests = client.requests(containing: "styles") - let componentRequests = client.requests(containing: "components") - - XCTAssertEqual(styleRequests.count, 1) - XCTAssertEqual(componentRequests.count, 1) - } - - // MARK: - Reset - - func testResetClearsEverything() async { - client.setResponse([Style](), for: StylesEndpoint.self) - - // Make a request to populate the log - _ = try? await client.request(StylesEndpoint(fileId: "test")) - - client.reset() - - XCTAssertEqual(client.requestCount, 0) - - // Should now throw because response was cleared - do { - _ = try await client.request(StylesEndpoint(fileId: "test")) - XCTFail("Expected error after reset") - } catch { - // Success - } - } - - // MARK: - No Response Configured - - func testThrowsWhenNoResponseConfigured() async { - let endpoint = StylesEndpoint(fileId: "test") - - do { - _ = try await client.request(endpoint) - XCTFail("Expected MockClientError.noResponseConfigured") - } catch let error as MockClientError { - if case let .noResponseConfigured(endpointName) = error { - XCTAssertTrue(endpointName.contains("StylesEndpoint")) - } else { - XCTFail("Unexpected error type") - } - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - // MARK: - Thread Safety - - func testConcurrentAccess() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - let localClient = try XCTUnwrap(client) - - // Make many concurrent requests - await withTaskGroup(of: Void.self) { group in - for _ in 0 ..< 100 { - group.addTask { - _ = try? await localClient.request(StylesEndpoint(fileId: "test")) - } - } - } - - XCTAssertEqual(client.requestCount, 100) - } - - // MARK: - Timestamp Tracking - - func testRequestTimestampsAreCaptured() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - - _ = try await client.request(StylesEndpoint(fileId: "test")) - - XCTAssertEqual(client.requestTimestamps.count, 1) - XCTAssertNotNil(client.requestTimestamps.first) - } - - func testRequestTimestampsAreInChronologicalOrder() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - - _ = try await client.request(StylesEndpoint(fileId: "test1")) - _ = try await client.request(StylesEndpoint(fileId: "test2")) - - XCTAssertEqual(client.requestTimestamps.count, 2) - XCTAssertLessThanOrEqual( - client.requestTimestamps[0], - client.requestTimestamps[1] - ) - } - - // MARK: - Request Delay - - func testRequestDelaySlowsDownRequests() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setRequestDelay(0.1) // 100ms - - let startTime = Date() - _ = try await client.request(StylesEndpoint(fileId: "test")) - let duration = Date().timeIntervalSince(startTime) - - XCTAssertGreaterThanOrEqual(duration, 0.09) // Allow small margin - } - - func testConcurrentRequestsCompleteInParallelTime() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setRequestDelay(0.15) // 150ms per request - let localClient = try XCTUnwrap(client) - - let startTime = Date() - await withTaskGroup(of: Void.self) { group in - group.addTask { _ = try? await localClient.request(StylesEndpoint(fileId: "a")) } - group.addTask { _ = try? await localClient.request(StylesEndpoint(fileId: "b")) } - } - let duration = Date().timeIntervalSince(startTime) - - // If parallel: ~150ms, If sequential: ~300ms - // Threshold 0.28s gives CI headroom while still proving parallelism - XCTAssertLessThan(duration, 0.28, "Concurrent requests should complete in parallel") - XCTAssertEqual(client.requestCount, 2) - } - - func testRequestsStartedWithinTimeWindow() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setRequestDelay(0.05) - let localClient = try XCTUnwrap(client) - - await withTaskGroup(of: Void.self) { group in - group.addTask { _ = try? await localClient.request(StylesEndpoint(fileId: "a")) } - group.addTask { _ = try? await localClient.request(StylesEndpoint(fileId: "b")) } - group.addTask { _ = try? await localClient.request(StylesEndpoint(fileId: "c")) } - } - - // All 3 requests should have started within 20ms of each other - XCTAssertTrue(client.requestsStartedWithin(seconds: 0.02)) - } - - func testResetClearsTimestampsAndDelay() async throws { - client.setResponse([Style](), for: StylesEndpoint.self) - client.setRequestDelay(0.1) - _ = try await client.request(StylesEndpoint(fileId: "test")) - - client.reset() - - XCTAssertEqual(client.requestTimestamps.count, 0) - - // After reset, delay should be 0, so request should be fast - client.setResponse([Style](), for: StylesEndpoint.self) - let startTime = Date() - _ = try await client.request(StylesEndpoint(fileId: "test")) - let duration = Date().timeIntervalSince(startTime) - - XCTAssertLessThan(duration, 0.05) - } -} diff --git a/Tests/FigmaAPITests/Mocks/MockClient.swift b/Tests/FigmaAPITests/Mocks/MockClient.swift deleted file mode 100644 index cde71f86..00000000 --- a/Tests/FigmaAPITests/Mocks/MockClient.swift +++ /dev/null @@ -1,138 +0,0 @@ -import FigmaAPI -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -/// Thread-safe mock client for testing FigmaAPI interactions. -/// Uses a serial queue for thread-safe synchronous access within async context. -public final class MockClient: Client, @unchecked Sendable { - private let queue = DispatchQueue(label: "com.exfig.tests.mockclient") - private var _responses: [String: Any] = [:] - private var _errors: [String: any Error] = [:] - private var _requestLog: [URLRequest] = [] - private var _requestTimestamps: [Date] = [] - private var _requestDelay: TimeInterval = 0 - - public init() {} - - // MARK: - Configuration - - /// Sets a successful response for a specific endpoint type. - public func setResponse(_ response: T.Content, for endpointType: T.Type) { - let key = String(describing: endpointType) - queue.sync { - self._responses[key] = response - self._errors.removeValue(forKey: key) - } - } - - /// Sets an error to throw for a specific endpoint type. - public func setError(_ error: any Error, for endpointType: (some Endpoint).Type) { - let key = String(describing: endpointType) - queue.sync { - self._errors[key] = error - self._responses.removeValue(forKey: key) - } - } - - /// Clears all configured responses and errors. - public func reset() { - queue.sync { - self._responses.removeAll() - self._errors.removeAll() - self._requestLog.removeAll() - self._requestTimestamps.removeAll() - self._requestDelay = 0 - } - } - - /// Sets artificial delay for each request (for testing parallel execution). - public func setRequestDelay(_ delay: TimeInterval) { - queue.sync { self._requestDelay = delay } - } - - // MARK: - Client Protocol - - public func request(_ endpoint: T) async throws -> T.Content { - let key = String(describing: type(of: endpoint)) - // swiftlint:disable:next force_unwrapping - let baseURL = URL(string: "https://api.figma.com/v1/")! - let request = try endpoint.makeRequest(baseURL: baseURL) - - // Record timestamp and get delay (thread-safe) - let delay = queue.sync { () -> TimeInterval in - self._requestLog.append(request) - self._requestTimestamps.append(Date()) - return self._requestDelay - } - - // Apply delay outside of sync block to allow parallel execution - if delay > 0 { - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - - return try queue.sync { - if let error = self._errors[key] { - throw error - } - - guard let response = self._responses[key] as? T.Content else { - throw MockClientError.noResponseConfigured(endpoint: key) - } - return response - } - } - - // MARK: - Inspection - - /// All requests made to this mock client. - public var requestLog: [URLRequest] { - queue.sync { _requestLog } - } - - /// Number of requests made. - public var requestCount: Int { - queue.sync { _requestLog.count } - } - - /// Returns the last request made, if any. - public var lastRequest: URLRequest? { - queue.sync { _requestLog.last } - } - - /// Returns requests matching a specific URL path component. - public func requests(containing path: String) -> [URLRequest] { - queue.sync { - _requestLog.filter { $0.url?.absoluteString.contains(path) ?? false } - } - } - - /// Timestamps when each request was made. - public var requestTimestamps: [Date] { - queue.sync { _requestTimestamps } - } - - /// Returns true if all requests started within the given time window. - /// Useful for verifying parallel execution. - public func requestsStartedWithin(seconds: TimeInterval) -> Bool { - let timestamps = queue.sync { _requestTimestamps } - guard timestamps.count >= 2 else { return true } - let sorted = timestamps.sorted() - return sorted.last!.timeIntervalSince(sorted.first!) < seconds - } -} - -// MARK: - Errors - -public enum MockClientError: Error, LocalizedError, Sendable { - case noResponseConfigured(endpoint: String) - - public var errorDescription: String? { - switch self { - case let .noResponseConfigured(endpoint): - "No mock response configured for endpoint: \(endpoint)" - } - } -} diff --git a/Tests/FigmaAPITests/NodeHashablePropertiesTests.swift b/Tests/FigmaAPITests/NodeHashablePropertiesTests.swift deleted file mode 100644 index 9dc6621a..00000000 --- a/Tests/FigmaAPITests/NodeHashablePropertiesTests.swift +++ /dev/null @@ -1,255 +0,0 @@ -@testable import FigmaAPI -import XCTest - -final class NodeHashablePropertiesTests: XCTestCase { - // MARK: - Canonical JSON Encoding - - func testEncodingProducesSortedKeysJSON() throws { - let props = NodeHashableProperties( - name: "icon", - type: "COMPONENT", - fills: [], - strokes: nil, - strokeWeight: nil, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(props) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - // Keys should be in alphabetical order - // "fills" should come before "name", "name" before "type" - let fillsIndex = try XCTUnwrap(json.range(of: "\"fills\"")?.lowerBound) - let nameIndex = try XCTUnwrap(json.range(of: "\"name\"")?.lowerBound) - let typeIndex = try XCTUnwrap(json.range(of: "\"type\"")?.lowerBound) - - XCTAssertLessThan(fillsIndex, nameIndex) - XCTAssertLessThan(nameIndex, typeIndex) - } - - // MARK: - Recursive Children - - func testChildrenAreIncludedRecursively() throws { - let childProps = NodeHashableProperties( - name: "shape", - type: "VECTOR", - fills: [HashablePaint(type: "SOLID", color: HashableColor(r: 0.5, g: 0.5, b: 0.5, a: 1.0))], - strokes: nil, - strokeWeight: 1.0, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let parentProps = NodeHashableProperties( - name: "icon", - type: "COMPONENT", - fills: [], - strokes: nil, - strokeWeight: nil, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: [childProps] - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(parentProps) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - // Child properties should be included - XCTAssertTrue(json.contains("\"children\"")) - XCTAssertTrue(json.contains("\"shape\"")) - XCTAssertTrue(json.contains("\"VECTOR\"")) - XCTAssertTrue(json.contains("0.5")) - } - - // MARK: - Excluded Properties - - func testBoundVariablesIsNotIncluded() throws { - // NodeHashableProperties should NOT have boundVariables property - // This test verifies the type definition is correct - let props = NodeHashableProperties( - name: "icon", - type: "COMPONENT", - fills: [], - strokes: nil, - strokeWeight: nil, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let encoder = JSONEncoder() - let data = try encoder.encode(props) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - // Should NOT contain boundVariables - XCTAssertFalse(json.contains("boundVariables")) - } - - func testAbsoluteBoundingBoxIsNotIncluded() throws { - // NodeHashableProperties should NOT have absoluteBoundingBox property - let props = NodeHashableProperties( - name: "icon", - type: "COMPONENT", - fills: [], - strokes: nil, - strokeWeight: nil, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let encoder = JSONEncoder() - let data = try encoder.encode(props) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - // Should NOT contain absoluteBoundingBox - XCTAssertFalse(json.contains("absoluteBoundingBox")) - XCTAssertFalse(json.contains("absoluteRenderBounds")) - } - - // MARK: - Float Normalization - - func testFloatValuesAreNormalizedInColors() throws { - // Colors with precision drift should normalize to same values - let color1 = HashableColor( - r: 0.33333334.normalized, - g: 0.66666667.normalized, - b: 0.5.normalized, - a: 1.0.normalized - ) - let color2 = HashableColor( - r: 0.33333333.normalized, - g: 0.66666666.normalized, - b: 0.5.normalized, - a: 1.0.normalized - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let data1 = try encoder.encode(color1) - let data2 = try encoder.encode(color2) - - XCTAssertEqual(data1, data2, "Colors with precision drift should encode identically after normalization") - } - - func testFloatValuesAreNormalizedInStrokeWeight() throws { - let props1 = NodeHashableProperties( - name: "icon", - type: "VECTOR", - fills: [], - strokes: nil, - strokeWeight: 1.0000001.normalized, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let props2 = NodeHashableProperties( - name: "icon", - type: "VECTOR", - fills: [], - strokes: nil, - strokeWeight: 1.0.normalized, - strokeAlign: nil, - strokeJoin: nil, - strokeCap: nil, - effects: nil, - opacity: nil, - blendMode: nil, - clipsContent: nil, - rotation: nil, - children: nil - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - let data1 = try encoder.encode(props1) - let data2 = try encoder.encode(props2) - - XCTAssertEqual(data1, data2, "StrokeWeight with precision drift should encode identically after normalization") - } - - // MARK: - Complete Visual Properties - - func testAllVisualPropertiesAreIncluded() throws { - let props = NodeHashableProperties( - name: "complex-icon", - type: "COMPONENT", - fills: [HashablePaint(type: "SOLID", color: HashableColor(r: 1, g: 0, b: 0, a: 1))], - strokes: [HashablePaint(type: "SOLID", color: HashableColor(r: 0, g: 0, b: 0, a: 1))], - strokeWeight: 2.0, - strokeAlign: "CENTER", - strokeJoin: "ROUND", - strokeCap: "ROUND", - effects: [HashableEffect(type: "DROP_SHADOW", radius: 4.0, offset: HashableVector(x: 0, y: 2))], - opacity: 0.9, - blendMode: "NORMAL", - clipsContent: true, - rotation: 1.5707963, - children: nil - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(props) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - // All visual properties should be present - XCTAssertTrue(json.contains("\"fills\"")) - XCTAssertTrue(json.contains("\"strokes\"")) - XCTAssertTrue(json.contains("\"strokeWeight\"")) - XCTAssertTrue(json.contains("\"strokeAlign\"")) - XCTAssertTrue(json.contains("\"strokeJoin\"")) - XCTAssertTrue(json.contains("\"strokeCap\"")) - XCTAssertTrue(json.contains("\"effects\"")) - XCTAssertTrue(json.contains("\"opacity\"")) - XCTAssertTrue(json.contains("\"blendMode\"")) - XCTAssertTrue(json.contains("\"clipsContent\"")) - XCTAssertTrue(json.contains("\"rotation\"")) - } -} diff --git a/Tests/FigmaAPITests/NodeTests.swift b/Tests/FigmaAPITests/NodeTests.swift deleted file mode 100644 index 191b228b..00000000 --- a/Tests/FigmaAPITests/NodeTests.swift +++ /dev/null @@ -1,257 +0,0 @@ -@testable import FigmaAPI -import Foundation -import XCTest - -final class LineHeightUnitTests: XCTestCase { - func testPixelsRawValue() { - XCTAssertEqual(LineHeightUnit.pixels.rawValue, "PIXELS") - } - - func testFontSizeRawValue() { - XCTAssertEqual(LineHeightUnit.fontSize.rawValue, "FONT_SIZE_%") - } - - func testIntrinsicRawValue() { - XCTAssertEqual(LineHeightUnit.intrinsic.rawValue, "INTRINSIC_%") - } - - func testDecoding() throws { - let json = "\"PIXELS\"" - let unit = try JSONDecoder().decode(LineHeightUnit.self, from: Data(json.utf8)) - XCTAssertEqual(unit, .pixels) - } -} - -final class TextCaseNodeTests: XCTestCase { - func testRawValues() { - XCTAssertEqual(TextCase.original.rawValue, "ORIGINAL") - XCTAssertEqual(TextCase.upper.rawValue, "UPPER") - XCTAssertEqual(TextCase.lower.rawValue, "LOWER") - XCTAssertEqual(TextCase.title.rawValue, "TITLE") - XCTAssertEqual(TextCase.smallCaps.rawValue, "SMALL_CAPS") - XCTAssertEqual(TextCase.smallCapsForced.rawValue, "SMALL_CAPS_FORCED") - } - - func testDecoding() throws { - let json = "\"UPPER\"" - let textCase = try JSONDecoder().decode(TextCase.self, from: Data(json.utf8)) - XCTAssertEqual(textCase, .upper) - } -} - -final class PaintTypeTests: XCTestCase { - func testRawValues() { - XCTAssertEqual(PaintType.solid.rawValue, "SOLID") - XCTAssertEqual(PaintType.image.rawValue, "IMAGE") - XCTAssertEqual(PaintType.rectangle.rawValue, "RECTANGLE") - XCTAssertEqual(PaintType.gradientLinear.rawValue, "GRADIENT_LINEAR") - XCTAssertEqual(PaintType.gradientRadial.rawValue, "GRADIENT_RADIAL") - XCTAssertEqual(PaintType.gradientAngular.rawValue, "GRADIENT_ANGULAR") - XCTAssertEqual(PaintType.gradientDiamond.rawValue, "GRADIENT_DIAMOND") - } -} - -final class PaintColorTests: XCTestCase { - func testDecoding() throws { - let json = """ - {"r": 1.0, "g": 0.5, "b": 0.25, "a": 0.8} - """ - - let color = try JSONDecoder().decode(PaintColor.self, from: Data(json.utf8)) - - XCTAssertEqual(color.r, 1.0) - XCTAssertEqual(color.g, 0.5) - XCTAssertEqual(color.b, 0.25) - XCTAssertEqual(color.a, 0.8) - } -} - -final class PaintTests: XCTestCase { - func testDecodingSolidPaint() throws { - let json = """ - { - "type": "SOLID", - "opacity": 0.9, - "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0} - } - """ - - let paint = try JSONDecoder().decode(Paint.self, from: Data(json.utf8)) - - XCTAssertEqual(paint.type, .solid) - XCTAssertEqual(paint.opacity, 0.9) - XCTAssertNotNil(paint.color) - } - - func testDecodingGradientPaint() throws { - let json = """ - { - "type": "GRADIENT_LINEAR", - "opacity": 1.0 - } - """ - - let paint = try JSONDecoder().decode(Paint.self, from: Data(json.utf8)) - - XCTAssertEqual(paint.type, .gradientLinear) - XCTAssertNil(paint.color) - } - - func testAsSolidWithSolidPaint() throws { - let json = """ - { - "type": "SOLID", - "opacity": 0.5, - "color": {"r": 0.0, "g": 1.0, "b": 0.0, "a": 1.0} - } - """ - - let paint = try JSONDecoder().decode(Paint.self, from: Data(json.utf8)) - let solid = paint.asSolid - - XCTAssertNotNil(solid) - XCTAssertEqual(solid?.opacity, 0.5) - XCTAssertEqual(solid?.color.g, 1.0) - } - - func testAsSolidWithNonSolidPaint() throws { - let json = """ - { - "type": "IMAGE" - } - """ - - let paint = try JSONDecoder().decode(Paint.self, from: Data(json.utf8)) - - XCTAssertNil(paint.asSolid) - } - - func testAsSolidWithMissingColor() throws { - let json = """ - { - "type": "SOLID" - } - """ - - let paint = try JSONDecoder().decode(Paint.self, from: Data(json.utf8)) - - XCTAssertNil(paint.asSolid) - } -} - -final class TypeStyleTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "fontFamily": "Roboto", - "fontPostScriptName": "Roboto-Bold", - "fontWeight": 700, - "fontSize": 16, - "lineHeightPx": 24, - "letterSpacing": 0.5, - "lineHeightUnit": "PIXELS", - "textCase": "UPPER" - } - """ - - let style = try JSONDecoder().decode(TypeStyle.self, from: Data(json.utf8)) - - XCTAssertEqual(style.fontFamily, "Roboto") - XCTAssertEqual(style.fontPostScriptName, "Roboto-Bold") - XCTAssertEqual(style.fontWeight, 700) - XCTAssertEqual(style.fontSize, 16) - XCTAssertEqual(style.lineHeightPx, 24) - XCTAssertEqual(style.letterSpacing, 0.5) - XCTAssertEqual(style.lineHeightUnit, .pixels) - XCTAssertEqual(style.textCase, .upper) - } - - func testDecodingWithNilOptionals() throws { - let json = """ - { - "fontWeight": 400, - "fontSize": 14, - "lineHeightPx": 20, - "letterSpacing": 0, - "lineHeightUnit": "FONT_SIZE_%" - } - """ - - let style = try JSONDecoder().decode(TypeStyle.self, from: Data(json.utf8)) - - XCTAssertNil(style.fontFamily) - XCTAssertNil(style.fontPostScriptName) - XCTAssertNil(style.textCase) - } -} - -final class DocumentTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "id": "123:456", - "name": "Primary Color", - "fills": [ - { - "type": "SOLID", - "color": {"r": 0.2, "g": 0.4, "b": 0.8, "a": 1.0} - } - ] - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - - XCTAssertEqual(document.id, "123:456") - XCTAssertEqual(document.name, "Primary Color") - XCTAssertEqual(document.fills.count, 1) - XCTAssertNil(document.style) - } - - func testDecodingWithStyle() throws { - let json = """ - { - "id": "789:012", - "name": "Heading", - "fills": [], - "style": { - "fontFamily": "Inter", - "fontWeight": 600, - "fontSize": 24, - "lineHeightPx": 32, - "letterSpacing": -0.5, - "lineHeightUnit": "PIXELS" - } - } - """ - - let document = try JSONDecoder().decode(Document.self, from: Data(json.utf8)) - - XCTAssertNotNil(document.style) - XCTAssertEqual(document.style?.fontFamily, "Inter") - } -} - -final class NodeResponseTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "nodes": { - "123:0": { - "document": { - "id": "123:0", - "name": "Test", - "fills": [] - } - } - } - } - """ - - let response = try JSONDecoder().decode(NodesResponse.self, from: Data(json.utf8)) - - XCTAssertEqual(response.nodes.count, 1) - XCTAssertNotNil(response.nodes["123:0"]) - XCTAssertEqual(response.nodes["123:0"]?.document.name, "Test") - } -} diff --git a/Tests/FigmaAPITests/NodesEndpointTests.swift b/Tests/FigmaAPITests/NodesEndpointTests.swift deleted file mode 100644 index 1aa3c42f..00000000 --- a/Tests/FigmaAPITests/NodesEndpointTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class NodesEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let endpoint = NodesEndpoint(fileId: "abc123", nodeIds: ["1:2", "1:3"]) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertTrue(request.url?.absoluteString.contains("files/abc123/nodes") ?? false) - XCTAssertTrue(request.url?.absoluteString.contains("ids=1:2,1:3") ?? false) - } - - func testMakeRequestWithSingleNodeId() throws { - let endpoint = NodesEndpoint(fileId: "file123", nodeIds: ["10:5"]) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertTrue(request.url?.absoluteString.contains("ids=10:5") ?? false) - } - - func testMakeRequestWithManyNodeIds() throws { - let nodeIds = (1 ... 100).map { "1:\($0)" } - let endpoint = NodesEndpoint(fileId: "file123", nodeIds: nodeIds) - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - // All node IDs should be joined with commas - let url = request.url?.absoluteString ?? "" - XCTAssertTrue(url.contains("1:1,1:2")) - XCTAssertTrue(url.contains("1:99,1:100")) - } - - // MARK: - Response Parsing - - func testContentParsesNodesResponse() throws { - let response: NodesResponse = try FixtureLoader.load("NodesResponse") - - let endpoint = NodesEndpoint(fileId: "test", nodeIds: []) - let nodes = endpoint.content(from: response) - - XCTAssertEqual(nodes.count, 5) - - // Check color node - let colorNode = nodes["1:2"] - XCTAssertNotNil(colorNode) - XCTAssertEqual(colorNode?.document.name, "primary/background") - XCTAssertEqual(colorNode?.document.fills.count, 1) - - let fill = colorNode?.document.fills[0] - XCTAssertEqual(fill?.type, .solid) - XCTAssertEqual(fill?.color?.r, 1.0) - XCTAssertEqual(fill?.color?.g, 1.0) - XCTAssertEqual(fill?.color?.b, 1.0) - } - - func testContentParsesTextStyleNode() throws { - let response: NodesResponse = try FixtureLoader.load("NodesResponse") - - let endpoint = NodesEndpoint(fileId: "test", nodeIds: []) - let nodes = endpoint.content(from: response) - - let textNode = nodes["2:1"] - XCTAssertNotNil(textNode) - - let style = textNode?.document.style - XCTAssertNotNil(style) - XCTAssertEqual(style?.fontFamily, "Inter") - XCTAssertEqual(style?.fontWeight, 700) - XCTAssertEqual(style?.fontSize, 32.0) - XCTAssertEqual(style?.lineHeightPx, 40.0) - XCTAssertEqual(style?.letterSpacing, -0.5) - XCTAssertEqual(style?.lineHeightUnit, .pixels) - } - - func testContentFromResponseWithBody() throws { - let data = try FixtureLoader.loadData("NodesResponse") - - let endpoint = NodesEndpoint(fileId: "test", nodeIds: []) - let nodes = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(nodes.count, 5) - } - - // MARK: - Paint Parsing - - func testSolidPaintExtraction() throws { - let response: NodesResponse = try FixtureLoader.load("NodesResponse") - - let endpoint = NodesEndpoint(fileId: "test", nodeIds: []) - let nodes = endpoint.content(from: response) - - let node = nodes["1:4"] - let paint = node?.document.fills.first - let solidPaint = paint?.asSolid - - XCTAssertNotNil(solidPaint) - XCTAssertEqual(solidPaint?.opacity, 0.8) - XCTAssertEqual(solidPaint?.color.r, 0.2) - } - - func testNonSolidPaintReturnsNil() { - let paint = Paint(type: .gradientLinear, blendMode: nil, opacity: 1.0, color: nil, gradientStops: nil) - XCTAssertNil(paint.asSolid) - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("not json".utf8) - let endpoint = NodesEndpoint(fileId: "test", nodeIds: []) - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } -} diff --git a/Tests/FigmaAPITests/RateLimitedClientTests.swift b/Tests/FigmaAPITests/RateLimitedClientTests.swift deleted file mode 100644 index 0f57925d..00000000 --- a/Tests/FigmaAPITests/RateLimitedClientTests.swift +++ /dev/null @@ -1,521 +0,0 @@ -// swiftlint:disable file_length -import Foundation -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif -@testable import FigmaAPI -import Testing -import XCTest - -final class RateLimitedClientAsyncTests: XCTestCase { - func testRetriesOn429WithRetryAfter() async throws { - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let baseClient = SequencedClient( - responses: [ - .failure(HTTPError(statusCode: 429, retryAfter: 0.01, body: Data())), - .success(Data("ok".utf8)), - ] - ) - let retryPolicy = RetryPolicy(maxRetries: 1, baseDelay: 0.001, maxDelay: 0.01, jitterFactor: 0.0) - let client = RateLimitedClient( - client: baseClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(DummyEndpoint()) - - XCTAssertEqual(result, "ok") - XCTAssertEqual(baseClient.callCount, 2) - } -} - -private struct DummyEndpoint: Endpoint { - typealias Content = String - - func makeRequest(baseURL: URL) -> URLRequest { - URLRequest(url: baseURL) - } - - func content(from response: URLResponse?, with body: Data) throws -> String { - _ = response - return String(data: body, encoding: .utf8) ?? "" - } -} - -private final class SequencedClient: Client, @unchecked Sendable { - private var responses: [Result] - private var calls = 0 - private let lock = NSLock() - - init(responses: [Result]) { - self.responses = responses - } - - func request(_ endpoint: T) async throws -> T.Content { - let result: Result = { - lock.lock() - defer { lock.unlock() } - calls += 1 - guard !responses.isEmpty else { - return .failure(HTTPError(statusCode: 500, retryAfter: nil, body: Data())) - } - return responses.removeFirst() - }() - switch result { - case let .success(data): - return try endpoint.content(from: nil, with: data) - case let .failure(error): - throw error - } - } - - var callCount: Int { - lock.lock() - defer { lock.unlock() } - return calls - } -} - -@Suite("RateLimitedClient") -struct RateLimitedClientSuite { - @Test("Request acquires rate limit token before making request") - func requestAcquiresTokenBeforeMaking() async throws { - let mockClient = MockRequestTrackingClient() - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let configID = ConfigID("test-config") - - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: configID - ) - - let endpoint = MockEndpoint(response: "success") - _ = try await client.request(endpoint) - - // Check that rate limiter tracked the request - let status = await rateLimiter.status() - #expect(status.configRequestCounts[configID] == 1) - - // Check that underlying client was called - #expect(mockClient.requestCount == 1) - } - - @Test("Multiple requests from same config tracked correctly") - func multipleRequestsTrackedCorrectly() async throws { - let mockClient = MockRequestTrackingClient() - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let configID = ConfigID("test-config") - - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: configID - ) - - let endpoint = MockEndpoint(response: "success") - _ = try await client.request(endpoint) - _ = try await client.request(endpoint) - _ = try await client.request(endpoint) - - let status = await rateLimiter.status() - #expect(status.configRequestCounts[configID] == 3) - #expect(mockClient.requestCount == 3) - } -} - -@Suite("RateLimitedClient Retry") -struct RateLimitedClientRetryTests { - @Test("Retries on 500 server error and succeeds") - func retriesOn500AndSucceeds() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 500, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 2) - } - - @Test("Retries on 502 bad gateway and succeeds") - func retriesOn502AndSucceeds() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 502, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 2) - } - - @Test("Retries on 503 service unavailable and succeeds") - func retriesOn503AndSucceeds() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 503, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 2) - } - - @Test("Retries on 504 gateway timeout and succeeds") - func retriesOn504AndSucceeds() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 504, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 2) - } - - @Test("Fails after max retries exhausted") - func failsAfterMaxRetriesExhausted() async throws { - let mockClient = MockSequenceClient() - // 5 failures = initial + 4 retries - for _ in 0 ..< 5 { - mockClient.addResponse(throwing: HTTPError(statusCode: 500, retryAfter: nil, body: Data())) - } - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - await #expect(throws: FigmaAPIError.self) { - _ = try await client.request(MockEndpoint(response: "ignored")) - } - // Initial request + 4 retries = 5 total - #expect(mockClient.requestCount == 5) - } - - @Test("Does not retry on 401 unauthorized") - func doesNotRetryOn401() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 401, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "should-not-reach") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - await #expect(throws: FigmaAPIError.self) { - _ = try await client.request(MockEndpoint(response: "ignored")) - } - #expect(mockClient.requestCount == 1) - } - - @Test("Does not retry on 404 not found") - func doesNotRetryOn404() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 404, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "should-not-reach") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - await #expect(throws: FigmaAPIError.self) { - _ = try await client.request(MockEndpoint(response: "ignored")) - } - #expect(mockClient.requestCount == 1) - } - - @Test("Retries multiple times before succeeding") - func retriesMultipleTimesBeforeSucceeding() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 500, retryAfter: nil, body: Data())) - mockClient.addResponse(throwing: HTTPError(statusCode: 502, retryAfter: nil, body: Data())) - mockClient.addResponse(throwing: HTTPError(statusCode: 503, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 4) - } - - @Test("Retries on URLError timeout") - func retriesOnURLErrorTimeout() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: URLError(.timedOut)) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let result = try await client.request(MockEndpoint(response: "ignored")) - #expect(result == "success") - #expect(mockClient.requestCount == 2) - } - - @Test("Uses retry-after from 429 response") - func usesRetryAfterFrom429Response() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 429, retryAfter: 0.01, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 1.0, jitterFactor: 0) // Long base delay - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - let start = Date() - let result = try await client.request(MockEndpoint(response: "ignored")) - let elapsed = Date().timeIntervalSince(start) - - #expect(result == "success") - // Should use retryAfter (0.01s) not baseDelay (1.0s) - #expect(elapsed < 0.5) - } - - @Test("Calls onRetry callback for each retry attempt") - func callsOnRetryCallbackForEachAttempt() async throws { - let mockClient = MockSequenceClient() - mockClient.addResponse(throwing: HTTPError(statusCode: 500, retryAfter: nil, body: Data())) - mockClient.addResponse(throwing: HTTPError(statusCode: 502, retryAfter: nil, body: Data())) - mockClient.addResponse(returning: "success") - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let retryPolicy = RetryPolicy(maxRetries: 4, baseDelay: 0.01, jitterFactor: 0) - - let tracker = RetryAttemptTracker() - let onRetry: @Sendable (Int, Error) async -> Void = { attempt, error in - await tracker.record(attempt: attempt, error: error) - } - - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy, - onRetry: onRetry - ) - - _ = try await client.request(MockEndpoint(response: "ignored")) - - let attempts = await tracker.attempts - #expect(attempts.count == 2) - #expect(attempts[0].attempt == 1) - #expect(attempts[1].attempt == 2) - } -} - -/// Thread-safe tracker for retry attempts. -actor RetryAttemptTracker { - private(set) var attempts: [(attempt: Int, error: Error)] = [] - - func record(attempt: Int, error: Error) { - attempts.append((attempt, error)) - } -} - -@Suite("RateLimitedClient Error Conversion") -struct RateLimitedClientErrorConversionTests { - @Test("Preserves underlying error message for non-HTTP/non-URLError") - func preservesUnderlyingErrorMessage() async throws { - let mockClient = MockSequenceClient() - // Custom error that is neither HTTPError nor URLError - let customError = CustomTestError(message: "JSON decoding failed: key 'nodes' not found") - mockClient.addResponse(throwing: customError) - - let rateLimiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - // No retries for this test - let retryPolicy = RetryPolicy(maxRetries: 0, baseDelay: 0.01, jitterFactor: 0) - let client = RateLimitedClient( - client: mockClient, - rateLimiter: rateLimiter, - configID: ConfigID("test"), - retryPolicy: retryPolicy - ) - - do { - _ = try await client.request(MockEndpoint(response: "ignored")) - Issue.record("Expected FigmaAPIError to be thrown") - } catch let error as FigmaAPIError { - // The underlying message should be preserved - #expect(error.statusCode == 0) - #expect(error.errorDescription?.contains("JSON decoding failed") == true) - #expect(error.errorDescription?.contains("nodes") == true) - } catch { - Issue.record("Expected FigmaAPIError but got \(type(of: error))") - } - } -} - -/// Custom test error for testing non-HTTP/non-URLError handling. -private struct CustomTestError: LocalizedError { - let message: String - - var errorDescription: String? { - message - } -} - -@Suite("HTTPError") -struct HTTPErrorTests { - @Test("HTTPError stores status code") - func storesStatusCode() { - let error = HTTPError(statusCode: 429, retryAfter: 30.0, body: Data()) - - #expect(error.statusCode == 429) - } - - @Test("HTTPError stores retry-after") - func storesRetryAfter() { - let error = HTTPError(statusCode: 429, retryAfter: 60.0, body: Data()) - - #expect(error.retryAfter == 60.0) - } - - @Test("HTTPError description includes status code") - func descriptionIncludesStatusCode() { - let error = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - - #expect(error.localizedDescription.contains("500")) - } -} - -// MARK: - Test Helpers - -/// Mock client that tracks request counts. -final class MockRequestTrackingClient: Client, @unchecked Sendable { - private(set) var requestCount = 0 - var shouldThrow: Error? - - func request(_ endpoint: T) async throws -> T.Content { - requestCount += 1 - if let error = shouldThrow { - throw error - } - // The endpoint must be our MockEndpoint to cast correctly - if let mockEndpoint = endpoint as? MockEndpoint { - // swiftlint:disable:next force_cast - return mockEndpoint.response as! T.Content - } - fatalError("MockRequestTrackingClient only supports MockEndpoint") - } -} - -/// Mock client that returns a sequence of responses/errors. -final class MockSequenceClient: Client, @unchecked Sendable { - private let queue = DispatchQueue(label: "mock-sequence-client") - private var responses: [Result] = [] - private var _requestCount = 0 - - var requestCount: Int { - queue.sync { _requestCount } - } - - func addResponse(returning value: String) { - queue.sync { responses.append(.success(value)) } - } - - func addResponse(throwing error: Error) { - queue.sync { responses.append(.failure(error)) } - } - - func request(_: T) async throws -> T.Content { - let result: Result = queue.sync { - _requestCount += 1 - guard !responses.isEmpty else { - fatalError("MockSequenceClient: no more responses configured") - } - return responses.removeFirst() - } - - switch result { - case let .success(value): - // swiftlint:disable:next force_cast - return value as! T.Content - case let .failure(error): - throw error - } - } -} - -/// Mock endpoint for testing. -struct MockEndpoint: Endpoint { - typealias Content = String - - let response: String - - func makeRequest(baseURL: URL) -> URLRequest { - URLRequest(url: baseURL) - } - - func content(from _: URLResponse?, with _: Data) throws -> String { - response - } -} diff --git a/Tests/FigmaAPITests/RedirectGuardDelegateTests.swift b/Tests/FigmaAPITests/RedirectGuardDelegateTests.swift deleted file mode 100644 index 8e8a18d2..00000000 --- a/Tests/FigmaAPITests/RedirectGuardDelegateTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -@testable import FigmaAPI -import Foundation -#if os(Linux) - import FoundationNetworking -#endif -import XCTest - -/// URLSessionTask.init() is deprecated in macOS 10.15, but is the only way to mock tasks. -@available(macOS, deprecated: 10.15) -final class RedirectGuardDelegateTests: XCTestCase { - private var delegate: RedirectGuardDelegate! - - override func setUp() { - super.setUp() - delegate = RedirectGuardDelegate() - } - - // MARK: - Same Host - - func testPreservesHeadersOnSameHostRedirect() { - let expectation = expectation(description: "redirect") - let original = makeRequest(url: "https://api.figma.com/v1/files/abc", headers: ["X-Figma-Token": "secret"]) - // URLSession copies headers to redirect request; simulate that behavior - let redirect = makeRequest( - url: "https://api.figma.com/v1/files/abc/nodes", - headers: ["X-Figma-Token": "secret"] - ) - let task = makeMockTask(originalRequest: original) - - delegate.urlSession( - URLSession.shared, - task: task, - willPerformHTTPRedirection: makeResponse(), - newRequest: redirect - ) { resultRequest in - XCTAssertEqual(resultRequest?.value(forHTTPHeaderField: "X-Figma-Token"), "secret") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Cross Host - - func testStripsHeadersOnCrossHostRedirect() { - let expectation = expectation(description: "redirect") - let original = makeRequest(url: "https://api.figma.com/v1/images/abc", headers: ["X-Figma-Token": "secret"]) - let redirect = makeRequest( - url: "https://s3.amazonaws.com/figma-images/image.png", - headers: ["X-Figma-Token": "secret"] - ) - let task = makeMockTask(originalRequest: original) - - delegate.urlSession( - URLSession.shared, - task: task, - willPerformHTTPRedirection: makeResponse(), - newRequest: redirect - ) { resultRequest in - XCTAssertNil(resultRequest?.value(forHTTPHeaderField: "X-Figma-Token")) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Scheme Downgrade - - func testStripsHeadersOnSchemeDowngrade() { - let expectation = expectation(description: "redirect") - let original = makeRequest(url: "https://api.figma.com/v1/files/abc", headers: ["Authorization": "Bearer tok"]) - let redirect = makeRequest( - url: "http://api.figma.com/v1/files/abc", - headers: ["Authorization": "Bearer tok"] - ) - let task = makeMockTask(originalRequest: original) - - delegate.urlSession( - URLSession.shared, - task: task, - willPerformHTTPRedirection: makeResponse(), - newRequest: redirect - ) { resultRequest in - XCTAssertNil(resultRequest?.value(forHTTPHeaderField: "Authorization")) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Fail-Closed (nil hosts) - - func testStripsHeadersWhenOriginalHostIsNil() { - let expectation = expectation(description: "redirect") - // file URL → nil host - let original = URLRequest(url: URL(fileURLWithPath: "/local/path")) - let redirect = makeRequest( - url: "https://api.figma.com/v1/files/abc", - headers: ["X-Figma-Token": "secret"] - ) - let task = makeMockTask(originalRequest: original) - - delegate.urlSession( - URLSession.shared, - task: task, - willPerformHTTPRedirection: makeResponse(), - newRequest: redirect - ) { resultRequest in - XCTAssertNil(resultRequest?.value(forHTTPHeaderField: "X-Figma-Token")) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - func testStripsHeadersWhenRedirectHostIsNil() { - let expectation = expectation(description: "redirect") - let original = makeRequest(url: "https://api.figma.com/v1/files/abc", headers: ["X-Figma-Token": "secret"]) - let redirect = URLRequest(url: URL(fileURLWithPath: "/local/redirect")) - let task = makeMockTask(originalRequest: original) - - delegate.urlSession( - URLSession.shared, - task: task, - willPerformHTTPRedirection: makeResponse(), - newRequest: redirect - ) { resultRequest in - XCTAssertNil(resultRequest?.value(forHTTPHeaderField: "X-Figma-Token")) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Helpers - - private func makeRequest(url: String, headers: [String: String] = [:]) -> URLRequest { - // swiftlint:disable:next force_unwrapping - var request = URLRequest(url: URL(string: url)!) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - return request - } - - private func makeMockTask(originalRequest: URLRequest?) -> URLSessionTask { - MockURLSessionTask(originalRequest: originalRequest) - } - - // Cross-platform HTTPURLResponse factory (Linux lacks the no-arg init). - // swiftlint:disable:next force_unwrapping - private func makeResponse() -> HTTPURLResponse { - HTTPURLResponse( - url: URL(string: "https://api.figma.com")!, - statusCode: 302, - httpVersion: nil, - headerFields: nil - )! - } -} - -// MARK: - Mock URLSessionTask - -/// Minimal mock that exposes `originalRequest` for redirect guard testing. -private final class MockURLSessionTask: URLSessionTask, @unchecked Sendable { - private let _originalRequest: URLRequest? - - @available(macOS, deprecated: 10.15) - init(originalRequest: URLRequest?) { - _originalRequest = originalRequest - super.init() - } - - override var originalRequest: URLRequest? { - _originalRequest - } -} diff --git a/Tests/FigmaAPITests/RetryPolicyTests.swift b/Tests/FigmaAPITests/RetryPolicyTests.swift deleted file mode 100644 index 47f7e596..00000000 --- a/Tests/FigmaAPITests/RetryPolicyTests.swift +++ /dev/null @@ -1,256 +0,0 @@ -@testable import FigmaAPI -import Foundation -import Testing - -@Suite("RetryPolicy") -struct RetryPolicyTests { - // MARK: - Delay Calculation - - @Test("First retry delay is approximately base delay") - func firstRetryDelayIsApproximatelyBaseDelay() { - let policy = RetryPolicy() - - // Attempt 0 means first retry - let delay = policy.delay(for: 0) - - // Base delay is 3.0, with 20% jitter: 2.4 to 3.6 - #expect(delay >= 2.4) - #expect(delay <= 3.6) - } - - @Test("Delay increases exponentially with attempt number") - func delayIncreasesExponentially() { - let policy = RetryPolicy(jitterFactor: 0) // No jitter for predictable test - - let delay0 = policy.delay(for: 0) // 3 * 2^0 = 3 - let delay1 = policy.delay(for: 1) // 3 * 2^1 = 6 - let delay2 = policy.delay(for: 2) // 3 * 2^2 = 12 - let delay3 = policy.delay(for: 3) // 3 * 2^3 = 24 - - #expect(delay0 == 3.0) - #expect(delay1 == 6.0) - #expect(delay2 == 12.0) - #expect(delay3 == 24.0) - } - - @Test("Delay is capped at maxDelay") - func delayIsCappedAtMaxDelay() { - let policy = RetryPolicy(maxDelay: 10.0, jitterFactor: 0) - - // Attempt 5 would be 2^5 = 32, but capped at 10 - let delay = policy.delay(for: 5) - - #expect(delay == 10.0) - } - - @Test("Jitter varies delay within expected range") - func jitterVariesDelayWithinRange() { - let policy = RetryPolicy(jitterFactor: 0.5) - - // Run multiple times to check jitter produces variation - var delays: Set = [] - for _ in 0 ..< 20 { - delays.insert(policy.delay(for: 0)) - } - - // With 50% jitter on base delay of 3.0: range is 1.5 to 4.5 - // Should have some variation - #expect(delays.count > 1, "Jitter should produce variation") - for delay in delays { - #expect(delay >= 1.5) - #expect(delay <= 4.5) - } - } - - @Test("Custom policy uses provided values") - func customPolicyUsesProvidedValues() { - let policy = RetryPolicy( - maxRetries: 5, - baseDelay: 2.0, - maxDelay: 60.0, - jitterFactor: 0.1 - ) - - #expect(policy.maxRetries == 5) - #expect(policy.baseDelay == 2.0) - #expect(policy.maxDelay == 60.0) - #expect(policy.jitterFactor == 0.1) - } - - // MARK: - Error Classification - - @Test("429 is retryable") - func rateLimitIsRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 429, retryAfter: 30.0, body: Data()) - - #expect(policy.isRetryable(error) == true) - } - - @Test("500 is retryable") - func internalServerErrorIsRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == true) - } - - @Test("502 is retryable") - func badGatewayIsRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 502, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == true) - } - - @Test("503 is retryable") - func serviceUnavailableIsRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 503, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == true) - } - - @Test("504 is retryable") - func gatewayTimeoutIsRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 504, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == true) - } - - @Test("400 is not retryable") - func badRequestIsNotRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 400, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == false) - } - - @Test("401 is not retryable") - func unauthorizedIsNotRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 401, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == false) - } - - @Test("403 is not retryable") - func forbiddenIsNotRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 403, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == false) - } - - @Test("404 is not retryable") - func notFoundIsNotRetryable() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 404, retryAfter: nil, body: Data()) - - #expect(policy.isRetryable(error) == false) - } - - @Test("URLError is retryable") - func urlErrorIsRetryable() { - let policy = RetryPolicy() - let error = URLError(.timedOut) - - #expect(policy.isRetryable(error) == true) - } - - @Test("URLError connection lost is retryable") - func connectionLostIsRetryable() { - let policy = RetryPolicy() - let error = URLError(.networkConnectionLost) - - #expect(policy.isRetryable(error) == true) - } - - @Test("URLError not connected is retryable") - func notConnectedIsRetryable() { - let policy = RetryPolicy() - let error = URLError(.notConnectedToInternet) - - #expect(policy.isRetryable(error) == true) - } - - @Test("URLError cancelled is not retryable") - func cancelledIsNotRetryable() { - let policy = RetryPolicy() - let error = URLError(.cancelled) - - #expect(policy.isRetryable(error) == false) - } - - @Test("Unknown error is not retryable") - func unknownErrorIsNotRetryable() { - struct UnknownError: Error {} - let policy = RetryPolicy() - - #expect(policy.isRetryable(UnknownError()) == false) - } - - // MARK: - shouldRetry - - @Test("shouldRetry returns true when within max retries") - func shouldRetryWithinMaxRetries() { - let policy = RetryPolicy(maxRetries: 4) - let error = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - - #expect(policy.shouldRetry(attempt: 0, error: error) == true) - #expect(policy.shouldRetry(attempt: 1, error: error) == true) - #expect(policy.shouldRetry(attempt: 2, error: error) == true) - #expect(policy.shouldRetry(attempt: 3, error: error) == true) - } - - @Test("shouldRetry returns false when max retries exceeded") - func shouldRetryExceedsMaxRetries() { - let policy = RetryPolicy(maxRetries: 4) - let error = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - - #expect(policy.shouldRetry(attempt: 4, error: error) == false) - #expect(policy.shouldRetry(attempt: 5, error: error) == false) - } - - @Test("shouldRetry returns false for non-retryable error") - func shouldRetryNonRetryableError() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 401, retryAfter: nil, body: Data()) - - #expect(policy.shouldRetry(attempt: 0, error: error) == false) - } - - // MARK: - delay with retryAfter - - @Test("delay uses retryAfter when provided for 429") - func delayUsesRetryAfterFor429() { - let policy = RetryPolicy() - let error = HTTPError(statusCode: 429, retryAfter: 45.0, body: Data()) - - let delay = policy.delay(for: 0, error: error) - - #expect(delay == 45.0) - } - - @Test("delay uses exponential backoff when no retryAfter") - func delayUsesExponentialBackoffWhenNoRetryAfter() { - let policy = RetryPolicy(jitterFactor: 0) - let error = HTTPError(statusCode: 500, retryAfter: nil, body: Data()) - - let delay = policy.delay(for: 1, error: error) - - #expect(delay == 6.0) // 3 * 2^1 - } - - @Test("delay respects retryAfter over maxDelay") - func delayRespectsRetryAfterOverMaxDelay() { - let policy = RetryPolicy(maxDelay: 30.0) - let error = HTTPError(statusCode: 429, retryAfter: 60.0, body: Data()) - - let delay = policy.delay(for: 0, error: error) - - // retryAfter should be respected even if > maxDelay - #expect(delay == 60.0) - } -} diff --git a/Tests/FigmaAPITests/SharedRateLimiterTests.swift b/Tests/FigmaAPITests/SharedRateLimiterTests.swift deleted file mode 100644 index d07eb7a0..00000000 --- a/Tests/FigmaAPITests/SharedRateLimiterTests.swift +++ /dev/null @@ -1,291 +0,0 @@ -@testable import FigmaAPI -import Testing - -@Suite("SharedRateLimiter") -struct SharedRateLimiterTests { - @Test("Initial status has full token bucket") - func initialStatusHasFullBucket() async { - let limiter = SharedRateLimiter(requestsPerMinute: 60.0, burstCapacity: 3.0) - let status = await limiter.status() - - #expect(status.availableTokens >= 2.9) - #expect(status.maxTokens == 3.0) - #expect(status.requestsPerMinute == 60.0) - #expect(status.isPaused == false) - } - - @Test("Acquire consumes token") - func acquireConsumesToken() async { - let limiter = SharedRateLimiter(requestsPerMinute: 60.0, burstCapacity: 3.0) - let configID = ConfigID("test-config") - - await limiter.acquire(for: configID) - let status = await limiter.status() - - // Should have consumed one token (allowing some margin for timing) - #expect(status.availableTokens < 3.0) - #expect(status.configRequestCounts[configID] == 1) - } - - @Test("Multiple acquires track request counts per config") - func multipleAcquiresTrackCounts() async { - let limiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) // High rate for fast test - let config1 = ConfigID("config-1") - let config2 = ConfigID("config-2") - - await limiter.acquire(for: config1) - await limiter.acquire(for: config1) - await limiter.acquire(for: config2) - - let status = await limiter.status() - - #expect(status.configRequestCounts[config1] == 2) - #expect(status.configRequestCounts[config2] == 1) - } - - @Test("Report rate limit sets pause") - func reportRateLimitSetsPause() async { - let limiter = SharedRateLimiter() - - await limiter.reportRateLimit(retryAfter: 5.0) - let status = await limiter.status() - - #expect(status.isPaused == true) - #expect(status.retryAfter == 5.0) - } - - @Test("Clear pause removes pause state") - func clearPauseRemovesPauseState() async { - let limiter = SharedRateLimiter() - - await limiter.reportRateLimit(retryAfter: 5.0) - await limiter.clearPause() - let status = await limiter.status() - - #expect(status.isPaused == false) - #expect(status.retryAfter == nil) - } - - @Test("Reset stats clears all tracking") - func resetStatsClearsTracking() async { - let limiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let configID = ConfigID("test") - - await limiter.acquire(for: configID) - await limiter.reportRateLimit(retryAfter: 10.0) - await limiter.resetAllStats() - - let status = await limiter.status() - - #expect(status.configRequestCounts.isEmpty) - #expect(status.pendingRequestCount == 0) - #expect(status.isPaused == false) - } - - @Test("Reset stats for specific config") - func resetStatsForSpecificConfig() async { - let limiter = SharedRateLimiter(requestsPerMinute: 600.0, burstCapacity: 10.0) - let config1 = ConfigID("config-1") - let config2 = ConfigID("config-2") - - await limiter.acquire(for: config1) - await limiter.acquire(for: config2) - await limiter.resetStats(for: config1) - - let status = await limiter.status() - - #expect(status.configRequestCounts[config1] == nil) - #expect(status.configRequestCounts[config2] == 1) - } - - @Test("Status description shows rate info") - func statusDescriptionShowsRateInfo() async { - let limiter = SharedRateLimiter(requestsPerMinute: 60.0, burstCapacity: 3.0) - let status = await limiter.status() - - #expect(status.description.contains("60.0 req/min")) - } - - @Test("Status description shows paused state") - func statusDescriptionShowsPausedState() async { - let limiter = SharedRateLimiter() - await limiter.reportRateLimit(retryAfter: 30.0) - let status = await limiter.status() - - #expect(status.description.contains("Paused")) - #expect(status.description.contains("30s")) - } - - @Test("Current rate is computed correctly") - func currentRateIsComputedCorrectly() async { - let limiter = SharedRateLimiter(requestsPerMinute: 60.0) - let status = await limiter.status() - - #expect(status.currentRate == 1.0) // 60 per minute = 1 per second - } - - @Test("ConfigID equality") - func configIDEquality() { - let id1 = ConfigID("test") - let id2 = ConfigID("test") - let id3 = ConfigID("other") - - #expect(id1 == id2) - #expect(id1 != id3) - } - - @Test("ConfigID hashable") - func configIDHashable() { - let id1 = ConfigID("test") - let id2 = ConfigID("test") - - var set = Set() - set.insert(id1) - set.insert(id2) - - #expect(set.count == 1) - } -} - -// MARK: - Rate Limit Distribution Fairness Tests - -@Suite("Rate Limit Distribution Fairness") -struct RateLimitDistributionTests { - @Test("Requests are distributed across configs") - func requestsDistributedAcrossConfigs() async { - let limiter = SharedRateLimiter(requestsPerMinute: 6000.0, burstCapacity: 100.0) // High rate for fast test - let config1 = ConfigID("config-1") - let config2 = ConfigID("config-2") - let config3 = ConfigID("config-3") - - // Simulate multiple requests from each config - for _ in 0 ..< 10 { - await limiter.acquire(for: config1) - await limiter.acquire(for: config2) - await limiter.acquire(for: config3) - } - - let status = await limiter.status() - - // Each config should have exactly 10 requests - #expect(status.configRequestCounts[config1] == 10) - #expect(status.configRequestCounts[config2] == 10) - #expect(status.configRequestCounts[config3] == 10) - } - - @Test("Total request count is accurate") - func totalRequestCountIsAccurate() async { - let limiter = SharedRateLimiter(requestsPerMinute: 6000.0, burstCapacity: 50.0) - let configs = (1 ... 5).map { ConfigID("config-\($0)") } - - // Each config makes 5 requests - for config in configs { - for _ in 0 ..< 5 { - await limiter.acquire(for: config) - } - } - - let status = await limiter.status() - - // Total should be 5 configs * 5 requests = 25 - let totalRequests = status.configRequestCounts.values.reduce(0, +) - #expect(totalRequests == 25) - } - - @Test("Rate limit fairness with interleaved requests") - func rateLimitFairnessWithInterleavedRequests() async { - let limiter = SharedRateLimiter(requestsPerMinute: 6000.0, burstCapacity: 100.0) - let fastConfig = ConfigID("fast-config") - let slowConfig = ConfigID("slow-config") - - // Fast config makes many rapid requests - for _ in 0 ..< 20 { - await limiter.acquire(for: fastConfig) - } - - // Slow config makes fewer requests - for _ in 0 ..< 5 { - await limiter.acquire(for: slowConfig) - } - - let status = await limiter.status() - - // Both should have their requests counted accurately - #expect(status.configRequestCounts[fastConfig] == 20) - #expect(status.configRequestCounts[slowConfig] == 5) - - // Total should reflect all requests - let totalRequests = status.configRequestCounts.values.reduce(0, +) - #expect(totalRequests == 25) - } - - @Test("Concurrent config requests track correctly") - func concurrentConfigRequestsTrackCorrectly() async { - let limiter = SharedRateLimiter(requestsPerMinute: 6000.0, burstCapacity: 100.0) - let configs = (1 ... 3).map { ConfigID("config-\($0)") } - let requestsPerConfig = 10 - - // Make concurrent requests from multiple configs - await withTaskGroup(of: Void.self) { group in - for config in configs { - for _ in 0 ..< requestsPerConfig { - group.addTask { - await limiter.acquire(for: config) - } - } - } - } - - let status = await limiter.status() - - // Verify each config got correct count despite concurrency - for config in configs { - #expect(status.configRequestCounts[config] == requestsPerConfig) - } - - let totalRequests = status.configRequestCounts.values.reduce(0, +) - #expect(totalRequests == configs.count * requestsPerConfig) - } - - @Test("Reset preserves fairness for subsequent batches") - func resetPreservesFairnessForSubsequentBatches() async { - let limiter = SharedRateLimiter(requestsPerMinute: 6000.0, burstCapacity: 100.0) - let config1 = ConfigID("batch1-config") - let config2 = ConfigID("batch2-config") - - // First batch - for _ in 0 ..< 10 { - await limiter.acquire(for: config1) - } - - // Reset for new batch - await limiter.resetAllStats() - - // Second batch - for _ in 0 ..< 5 { - await limiter.acquire(for: config2) - } - - let status = await limiter.status() - - // Only second batch should be counted - #expect(status.configRequestCounts[config1] == nil) - #expect(status.configRequestCounts[config2] == 5) - let totalRequests = status.configRequestCounts.values.reduce(0, +) - #expect(totalRequests == 5) - } - - @Test("Rate limit pause affects all configs equally") - func rateLimitPauseAffectsAllConfigsEqually() async { - let limiter = SharedRateLimiter(requestsPerMinute: 60.0, burstCapacity: 3.0) - - // Report a rate limit - await limiter.reportRateLimit(retryAfter: 30.0) - - let status = await limiter.status() - - // All configs should see the pause - #expect(status.isPaused == true) - #expect(status.retryAfter == 30.0) - } -} diff --git a/Tests/FigmaAPITests/StylesEndpointTests.swift b/Tests/FigmaAPITests/StylesEndpointTests.swift deleted file mode 100644 index 2e3aa96a..00000000 --- a/Tests/FigmaAPITests/StylesEndpointTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import CustomDump -@testable import FigmaAPI -import XCTest - -final class StylesEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let endpoint = StylesEndpoint(fileId: "abc123") - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/abc123/styles" - ) - } - - func testMakeRequestWithSpecialCharactersInFileId() throws { - let endpoint = StylesEndpoint(fileId: "file-with-dashes") - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/file-with-dashes/styles" - ) - } - - // MARK: - Response Parsing - - func testContentParsesStylesResponse() throws { - let response: StylesResponse = try FixtureLoader.load("StylesResponse") - - let endpoint = StylesEndpoint(fileId: "test") - let styles = endpoint.content(from: response) - - XCTAssertEqual(styles.count, 5) - - // Check first fill style - let firstStyle = styles[0] - XCTAssertEqual(firstStyle.name, "primary/background") - XCTAssertEqual(firstStyle.nodeId, "1:2") - XCTAssertEqual(firstStyle.styleType, .fill) - XCTAssertEqual(firstStyle.description, "") - - // Check style with description - let styleWithDescription = styles[1] - XCTAssertEqual(styleWithDescription.name, "primary/text") - XCTAssertEqual(styleWithDescription.description, "ios") - - // Check text style - let textStyle = styles[3] - XCTAssertEqual(textStyle.name, "heading/large") - XCTAssertEqual(textStyle.styleType, .text) - } - - func testContentFromResponseWithBody() throws { - let data = try FixtureLoader.loadData("StylesResponse") - - let endpoint = StylesEndpoint(fileId: "test") - let styles = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(styles.count, 5) - XCTAssertEqual(styles[0].name, "primary/background") - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("invalid json".utf8) - let endpoint = StylesEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } - - func testContentThrowsOnFigmaError() throws { - let errorJSONString = """ - { - "status": 404, - "err": "Not found" - } - """ - let errorJSON = Data(errorJSONString.utf8) - - let endpoint = StylesEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: errorJSON)) { error in - XCTAssertTrue(error is FigmaClientError) - } - } -} diff --git a/Tests/FigmaAPITests/UpdateVariablesEndpointTests.swift b/Tests/FigmaAPITests/UpdateVariablesEndpointTests.swift deleted file mode 100644 index b630d8b3..00000000 --- a/Tests/FigmaAPITests/UpdateVariablesEndpointTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -@testable import FigmaAPI -import XCTest - -final class UpdateVariablesEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let body = VariablesUpdateRequest(variables: []) - let endpoint = UpdateVariablesEndpoint(fileId: "abc123", body: body) - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/abc123/variables" - ) - } - - func testMakeRequestUsesPOSTMethod() throws { - let body = VariablesUpdateRequest(variables: []) - let endpoint = UpdateVariablesEndpoint(fileId: "test", body: body) - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual(request.httpMethod, "POST") - } - - func testMakeRequestSetsContentTypeHeader() throws { - let body = VariablesUpdateRequest(variables: []) - let endpoint = UpdateVariablesEndpoint(fileId: "test", body: body) - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - } - - func testMakeRequestIncludesBody() throws { - let update = VariableUpdate( - id: "VariableID:123:456", - codeSyntax: VariableCodeSyntax(iOS: "Color.primary") - ) - let body = VariablesUpdateRequest(variables: [update]) - let endpoint = UpdateVariablesEndpoint(fileId: "test", body: body) - // swiftlint:disable:next force_unwrapping - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = try endpoint.makeRequest(baseURL: baseURL) - - XCTAssertNotNil(request.httpBody) - - let decoded = try JSONDecoder().decode(VariablesUpdateRequest.self, from: XCTUnwrap(request.httpBody)) - XCTAssertEqual(decoded.variables.count, 1) - XCTAssertEqual(decoded.variables[0].id, "VariableID:123:456") - XCTAssertEqual(decoded.variables[0].action, "UPDATE") - XCTAssertEqual(decoded.variables[0].codeSyntax?.iOS, "Color.primary") - } - - // MARK: - Response Parsing - - func testContentParsesSuccessResponse() throws { - let json = """ - { - "status": 200, - "error": false - } - """ - let data = Data(json.utf8) - - let body = VariablesUpdateRequest(variables: []) - let endpoint = UpdateVariablesEndpoint(fileId: "test", body: body) - let response = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(response.status, 200) - XCTAssertEqual(response.error, false) - } - - func testContentParsesErrorResponse() throws { - let json = """ - { - "status": 403, - "error": true - } - """ - let data = Data(json.utf8) - - let body = VariablesUpdateRequest(variables: []) - let endpoint = UpdateVariablesEndpoint(fileId: "test", body: body) - let response = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(response.status, 403) - XCTAssertEqual(response.error, true) - } -} diff --git a/Tests/FigmaAPITests/VariableUpdateTests.swift b/Tests/FigmaAPITests/VariableUpdateTests.swift deleted file mode 100644 index a8f59300..00000000 --- a/Tests/FigmaAPITests/VariableUpdateTests.swift +++ /dev/null @@ -1,157 +0,0 @@ -@testable import FigmaAPI -import XCTest - -final class VariableUpdateTests: XCTestCase { - // MARK: - VariableUpdate - - func testVariableUpdateInitSetsActionToUpdate() { - let update = VariableUpdate( - id: "VariableID:123:456", - codeSyntax: VariableCodeSyntax(iOS: "Color.primary") - ) - - XCTAssertEqual(update.action, "UPDATE") - XCTAssertEqual(update.id, "VariableID:123:456") - } - - func testVariableUpdateEncodesToJSON() throws { - let update = VariableUpdate( - id: "VariableID:123:456", - codeSyntax: VariableCodeSyntax(iOS: "Color.primary") - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - let data = try encoder.encode(update) - let json = String(data: data, encoding: .utf8) - - XCTAssertNotNil(json) - XCTAssertTrue(try XCTUnwrap(json?.contains("\"action\":\"UPDATE\""))) - XCTAssertTrue(try XCTUnwrap(json?.contains("\"id\":\"VariableID:123:456\""))) - XCTAssertTrue(try XCTUnwrap(json?.contains("\"iOS\":\"Color.primary\""))) - } - - func testVariableUpdateDecodesFromJSON() throws { - let json = """ - { - "action": "UPDATE", - "id": "VariableID:123:456", - "codeSyntax": { - "iOS": "Color.primary" - } - } - """ - let data = Data(json.utf8) - - let update = try JSONDecoder().decode(VariableUpdate.self, from: data) - - XCTAssertEqual(update.action, "UPDATE") - XCTAssertEqual(update.id, "VariableID:123:456") - XCTAssertEqual(update.codeSyntax?.iOS, "Color.primary") - } - - // MARK: - VariableCodeSyntax - - func testVariableCodeSyntaxInitWithiOSOnly() { - let syntax = VariableCodeSyntax(iOS: "Color.primary") - - XCTAssertEqual(syntax.iOS, "Color.primary") - XCTAssertNil(syntax.ANDROID) - XCTAssertNil(syntax.WEB) - } - - func testVariableCodeSyntaxInitWithAllPlatforms() { - let syntax = VariableCodeSyntax( - iOS: "Color.primary", - android: "R.color.primary", - web: "var(--primary)" - ) - - XCTAssertEqual(syntax.iOS, "Color.primary") - XCTAssertEqual(syntax.ANDROID, "R.color.primary") - XCTAssertEqual(syntax.WEB, "var(--primary)") - } - - func testVariableCodeSyntaxEncodesToJSON() throws { - let syntax = VariableCodeSyntax( - iOS: "Color.primary", - android: "R.color.primary" - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - let data = try encoder.encode(syntax) - let json = String(data: data, encoding: .utf8) - - XCTAssertNotNil(json) - XCTAssertTrue(try XCTUnwrap(json?.contains("\"iOS\":\"Color.primary\""))) - XCTAssertTrue(try XCTUnwrap(json?.contains("\"ANDROID\":\"R.color.primary\""))) - } - - func testVariableCodeSyntaxDecodesFromJSON() throws { - let json = """ - { - "iOS": "Color.accent", - "ANDROID": "R.color.accent", - "WEB": "var(--accent)" - } - """ - let data = Data(json.utf8) - - let syntax = try JSONDecoder().decode(VariableCodeSyntax.self, from: data) - - XCTAssertEqual(syntax.iOS, "Color.accent") - XCTAssertEqual(syntax.ANDROID, "R.color.accent") - XCTAssertEqual(syntax.WEB, "var(--accent)") - } - - // MARK: - VariablesUpdateRequest - - func testVariablesUpdateRequestEncodesToJSON() throws { - let updates = [ - VariableUpdate( - id: "VariableID:1:1", - codeSyntax: VariableCodeSyntax(iOS: "Color.primary") - ), - VariableUpdate( - id: "VariableID:1:2", - codeSyntax: VariableCodeSyntax(iOS: "Color.secondary") - ), - ] - let request = VariablesUpdateRequest(variables: updates) - - let data = try JSONEncoder().encode(request) - let decoded = try JSONDecoder().decode(VariablesUpdateRequest.self, from: data) - - XCTAssertEqual(decoded.variables.count, 2) - XCTAssertEqual(decoded.variables[0].id, "VariableID:1:1") - XCTAssertEqual(decoded.variables[1].id, "VariableID:1:2") - } - - // MARK: - UpdateVariablesResponse - - func testUpdateVariablesResponseDecodesFromJSON() throws { - let json = """ - { - "status": 200, - "error": false - } - """ - let data = Data(json.utf8) - - let response = try JSONDecoder().decode(UpdateVariablesResponse.self, from: data) - - XCTAssertEqual(response.status, 200) - XCTAssertEqual(response.error, false) - } - - func testUpdateVariablesResponseHandlesNilFields() throws { - let json = "{}" - let data = Data(json.utf8) - - let response = try JSONDecoder().decode(UpdateVariablesResponse.self, from: data) - - XCTAssertNil(response.status) - XCTAssertNil(response.error) - } -} diff --git a/Tests/FigmaAPITests/VariablesEndpointTests.swift b/Tests/FigmaAPITests/VariablesEndpointTests.swift deleted file mode 100644 index e7e4d286..00000000 --- a/Tests/FigmaAPITests/VariablesEndpointTests.swift +++ /dev/null @@ -1,167 +0,0 @@ -import CustomDump -import ExFigCore -@testable import FigmaAPI -import XCTest - -final class VariablesEndpointTests: XCTestCase { - // MARK: - URL Construction - - func testMakeRequestConstructsCorrectURL() throws { - let endpoint = VariablesEndpoint(fileId: "abc123") - let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/")) - - let request = endpoint.makeRequest(baseURL: baseURL) - - XCTAssertEqual( - request.url?.absoluteString, - "https://api.figma.com/v1/files/abc123/variables/local" - ) - } - - // MARK: - Response Parsing - - func testContentParsesVariablesResponse() throws { - let response: VariablesResponse = try FixtureLoader.load("VariablesResponse") - - let endpoint = VariablesEndpoint(fileId: "test") - let meta = endpoint.content(from: response) - - // Check collections - XCTAssertEqual(meta.variableCollections.count, 1) - - let collection = meta.variableCollections["VariableCollectionId:1:1"] - XCTAssertNotNil(collection) - XCTAssertEqual(collection?.name, "Colors") - XCTAssertEqual(collection?.modes.count, 2) - XCTAssertEqual(collection?.modes[0].name, "Light") - XCTAssertEqual(collection?.modes[1].name, "Dark") - - // Check variables - XCTAssertEqual(meta.variables.count, 2) - - let bgVariable = meta.variables["VariableID:1:2"] - XCTAssertNotNil(bgVariable) - XCTAssertEqual(bgVariable?.name, "primary/background") - XCTAssertEqual(bgVariable?.description, "Main background color") - } - - func testContentParsesColorValuesByMode() throws { - let response: VariablesResponse = try FixtureLoader.load("VariablesResponse") - - let endpoint = VariablesEndpoint(fileId: "test") - let meta = endpoint.content(from: response) - - let bgVariable = meta.variables["VariableID:1:2"] - - // Check light mode color (white) - if case let .color(lightColor) = bgVariable?.valuesByMode["1:0"] { - XCTAssertEqual(lightColor.r, 1.0) - XCTAssertEqual(lightColor.g, 1.0) - XCTAssertEqual(lightColor.b, 1.0) - } else { - XCTFail("Expected color value for light mode") - } - - // Check dark mode color (dark gray) - if case let .color(darkColor) = bgVariable?.valuesByMode["1:1"] { - XCTAssertEqual(darkColor.r, 0.1) - XCTAssertEqual(darkColor.g, 0.1) - XCTAssertEqual(darkColor.b, 0.1) - } else { - XCTFail("Expected color value for dark mode") - } - } - - func testContentFromResponseWithBody() throws { - let data = try FixtureLoader.loadData("VariablesResponse") - - let endpoint = VariablesEndpoint(fileId: "test") - let meta = try endpoint.content(from: nil, with: data) - - XCTAssertEqual(meta.variableCollections.count, 1) - XCTAssertEqual(meta.variables.count, 2) - } - - // MARK: - Mode Parsing - - func testModesParsing() throws { - let response: VariablesResponse = try FixtureLoader.load("VariablesResponse") - - let endpoint = VariablesEndpoint(fileId: "test") - let meta = endpoint.content(from: response) - - let collection = meta.variableCollections.values.first - let lightMode = collection?.modes.first { $0.name == "Light" } - let darkMode = collection?.modes.first { $0.name == "Dark" } - - XCTAssertNotNil(lightMode) - XCTAssertNotNil(darkMode) - XCTAssertEqual(lightMode?.modeId, "1:0") - XCTAssertEqual(darkMode?.modeId, "1:1") - } - - // MARK: - Error Handling - - func testContentThrowsOnInvalidJSON() { - let invalidData = Data("invalid".utf8) - let endpoint = VariablesEndpoint(fileId: "test") - - XCTAssertThrowsError(try endpoint.content(from: nil, with: invalidData)) - } - - func testJSONCodecDirectDecode() throws { - // Test decoding VariableCollectionValue - API uses camelCase - let collectionJson = """ - { - "defaultModeId": "1:0", - "id": "VariableCollectionId:1:1", - "name": "Colors", - "modes": [ - { "modeId": "1:0", "name": "Light" } - ], - "variableIds": ["id1"] - } - """ - let collectionData = Data(collectionJson.utf8) - - let collection = try JSONCodec.decode(VariableCollectionValue.self, from: collectionData) - XCTAssertEqual(collection.name, "Colors") - } - - func testFoundationDecoderDirectDecode() throws { - // Test Foundation decoder with camelCase JSON (matching Figma API) - let json = """ - { - "meta": { - "variableCollections": { - "VariableCollectionId:1:1": { - "defaultModeId": "1:0", - "id": "VariableCollectionId:1:1", - "name": "Colors", - "modes": [ - { "modeId": "1:0", "name": "Light" } - ], - "variableIds": ["id1"] - } - }, - "variables": { - "VariableID:1:2": { - "id": "VariableID:1:2", - "name": "test", - "variableCollectionId": "VariableCollectionId:1:1", - "valuesByMode": { - "1:0": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } - }, - "description": "Test" - } - } - } - } - """ - let data = Data(json.utf8) - let decoder = JSONDecoder() - - let response = try decoder.decode(VariablesResponse.self, from: data) - XCTAssertEqual(response.meta.variableCollections.count, 1) - } -} diff --git a/Tests/FigmaAPITests/VariablesTests.swift b/Tests/FigmaAPITests/VariablesTests.swift deleted file mode 100644 index 15347ac6..00000000 --- a/Tests/FigmaAPITests/VariablesTests.swift +++ /dev/null @@ -1,237 +0,0 @@ -@testable import FigmaAPI -import Foundation -import XCTest - -final class ModeTests: XCTestCase { - func testDecoding() throws { - let json = """ - {"modeId": "123:0", "name": "Light"} - """ - - let mode = try JSONDecoder().decode(Mode.self, from: Data(json.utf8)) - - XCTAssertEqual(mode.modeId, "123:0") - XCTAssertEqual(mode.name, "Light") - } -} - -final class VariableCollectionValueTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "defaultModeId": "123:0", - "id": "collection-1", - "name": "Colors", - "modes": [ - {"modeId": "123:0", "name": "Light"}, - {"modeId": "123:1", "name": "Dark"} - ], - "variableIds": ["var-1", "var-2"] - } - """ - - let collection = try JSONDecoder().decode(VariableCollectionValue.self, from: Data(json.utf8)) - - XCTAssertEqual(collection.defaultModeId, "123:0") - XCTAssertEqual(collection.id, "collection-1") - XCTAssertEqual(collection.name, "Colors") - XCTAssertEqual(collection.modes.count, 2) - XCTAssertEqual(collection.variableIds, ["var-1", "var-2"]) - } -} - -final class VariableAliasTests: XCTestCase { - func testDecoding() throws { - let json = """ - {"id": "alias-123", "type": "VARIABLE_ALIAS"} - """ - - let alias = try JSONDecoder().decode(VariableAlias.self, from: Data(json.utf8)) - - XCTAssertEqual(alias.id, "alias-123") - XCTAssertEqual(alias.type, "VARIABLE_ALIAS") - } - - func testEncoding() throws { - let alias = VariableAlias(id: "alias-456", type: "VARIABLE_ALIAS") - - let data = try JSONEncoder().encode(alias) - let decoded = try JSONDecoder().decode(VariableAlias.self, from: data) - - XCTAssertEqual(decoded.id, "alias-456") - XCTAssertEqual(decoded.type, "VARIABLE_ALIAS") - } -} - -final class ValuesByModeTests: XCTestCase { - func testDecodingVariableAlias() throws { - let json = """ - {"id": "alias-123", "type": "VARIABLE_ALIAS"} - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .variableAlias(alias) = value { - XCTAssertEqual(alias.id, "alias-123") - } else { - XCTFail("Expected variableAlias case") - } - } - - func testDecodingColor() throws { - let json = """ - {"r": 1.0, "g": 0.5, "b": 0.25, "a": 1.0} - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .color(color) = value { - XCTAssertEqual(color.r, 1.0) - XCTAssertEqual(color.g, 0.5) - XCTAssertEqual(color.b, 0.25) - XCTAssertEqual(color.a, 1.0) - } else { - XCTFail("Expected color case") - } - } - - func testDecodingString() throws { - let json = """ - "test string" - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .string(str) = value { - XCTAssertEqual(str, "test string") - } else { - XCTFail("Expected string case") - } - } - - func testDecodingNumber() throws { - let json = """ - 42.5 - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .number(num) = value { - XCTAssertEqual(num, 42.5) - } else { - XCTFail("Expected number case") - } - } - - func testDecodingBoolean() throws { - let json = """ - true - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .boolean(bool) = value { - XCTAssertTrue(bool) - } else { - XCTFail("Expected boolean case") - } - } - - func testDecodingBooleanFalse() throws { - let json = """ - false - """ - - let value = try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8)) - - if case let .boolean(bool) = value { - XCTAssertFalse(bool) - } else { - XCTFail("Expected boolean case") - } - } - - func testDecodingInvalidDataThrows() { - let json = """ - [1, 2, 3] - """ - - XCTAssertThrowsError(try JSONDecoder().decode(ValuesByMode.self, from: Data(json.utf8))) - } -} - -final class VariableValueTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "id": "var-1", - "name": "primary", - "variableCollectionId": "collection-1", - "valuesByMode": { - "123:0": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0} - }, - "description": "Primary color" - } - """ - - let variable = try JSONDecoder().decode(VariableValue.self, from: Data(json.utf8)) - - XCTAssertEqual(variable.id, "var-1") - XCTAssertEqual(variable.name, "primary") - XCTAssertEqual(variable.variableCollectionId, "collection-1") - XCTAssertEqual(variable.description, "Primary color") - XCTAssertEqual(variable.valuesByMode.count, 1) - } -} - -final class VariablesMetaTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "variableCollections": { - "collection-1": { - "defaultModeId": "123:0", - "id": "collection-1", - "name": "Colors", - "modes": [{"modeId": "123:0", "name": "Light"}], - "variableIds": ["var-1"] - } - }, - "variables": { - "var-1": { - "id": "var-1", - "name": "primary", - "variableCollectionId": "collection-1", - "valuesByMode": {"123:0": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}, - "description": "" - } - } - } - """ - - let meta = try JSONDecoder().decode(VariablesMeta.self, from: Data(json.utf8)) - - XCTAssertEqual(meta.variableCollections.count, 1) - XCTAssertEqual(meta.variables.count, 1) - XCTAssertNotNil(meta.variableCollections["collection-1"]) - XCTAssertNotNil(meta.variables["var-1"]) - } -} - -final class VariablesResponseTests: XCTestCase { - func testDecoding() throws { - let json = """ - { - "meta": { - "variableCollections": {}, - "variables": {} - } - } - """ - - let response = try JSONDecoder().decode(VariablesResponse.self, from: Data(json.utf8)) - - XCTAssertTrue(response.meta.variableCollections.isEmpty) - XCTAssertTrue(response.meta.variables.isEmpty) - } -} From 7aaaa64e9f0d31b4581abed243f65fb53e8fdc44 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Mon, 16 Mar 2026 19:43:51 +0500 Subject: [PATCH 2/2] ci: trigger CI run