From d15756b8b1bb6ced6055fe56a9142c93407e824e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sun, 6 Sep 2026 00:03:10 -0700 Subject: [PATCH] feat(notifications,uploads): delete a notification; size images to server caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two papercuts from work-consolidation.md §3. P1 — DELETE /api/notifications/:id exists and is documented, but iOS only had read and mark-all-read, so a notification could never be cleared from the tray. - APIClient.deleteNotification(id:) against the 204 route - Swipe-to-delete in NotificationsView, applied optimistically: the row goes on swipe and comes back only if the server rejects it, with its own alert. The alert is a separate channel from `errorMessage`, which drives the full-screen empty state and would have swallowed the message P3 — the client resized uploads to 2048px per side while the backend resizes every image to 1200px and 1.4MB regardless (messages, avatars and documents all route through resizeAvatarToLimit). Every photo posted therefore carried ~2.9x the pixels the server kept, for nothing. - New ServerLimits model + APIClient.serverLimits() against GET /api/limits (public, no auth), and ServerLimitsStore holding the result for the process - RootView refreshes it once at launch. Failure is not an error path: the fallback caps are the values the deployed backend reports today, so an upload is never blocked by a missing fetch - ImageUploadProcessor takes the caps as a parameter instead of hardcoding them, and derives its ladder from them: the server cap leads, and rungs at or above it are dropped. maxPixels 1200 gives [1200, 1000, 800]; a raised cap is honoured with no client release. The store is read off the main actor from a detached task, so it is lock-guarded rather than actor-isolated - ImageUploadProcessorTests pinned the old constants; they now pin the derived ladders and pass explicit caps, so results don't depend on whether GET /api/limits happened to answer during the run Also documents in CLAUDE.md that parallel worktrees must not share a simulator or DerivedData — doing so killed a test runner mid-suite during this work and restarted it into a sibling worktree's test bundle, producing a bogus failure. Verified: 861 tests, 0 failures (iPhone 16 Pro · D9B21F4D-…, isolated derivedDataPath, -parallel-testing-enabled NO, E2E skipped). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KD1sv3y8YWsDBiG31tUJWo --- CLAUDE.md | 4 + InterlinedList.xcodeproj/project.pbxproj | 20 +++++ InterlinedList/Models/ServerLimits.swift | 66 +++++++++++++++ .../Services/APIClient+Limits.swift | 15 ++++ InterlinedList/Services/APIClient.swift | 6 ++ .../Services/ImageUploadProcessor.swift | 47 +++++++---- .../Services/ServerLimitsStore.swift | 40 +++++++++ InterlinedList/Views/NotificationsView.swift | 33 ++++++++ InterlinedList/Views/RootView.swift | 5 ++ .../APIClientTests/APIClientLimitsTests.swift | 74 +++++++++++++++++ .../APIClientNotificationsTests.swift | 48 +++++++++++ .../ImageUploadProcessorTests.swift | 83 +++++++++++++++---- .../ServiceTests/ServerLimitsStoreTests.swift | 69 +++++++++++++++ 13 files changed, 477 insertions(+), 33 deletions(-) create mode 100644 InterlinedList/Models/ServerLimits.swift create mode 100644 InterlinedList/Services/APIClient+Limits.swift create mode 100644 InterlinedList/Services/ServerLimitsStore.swift create mode 100644 InterlinedListTests/APIClientTests/APIClientLimitsTests.swift create mode 100644 InterlinedListTests/ServiceTests/ServerLimitsStoreTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3744ca1..c224be3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,10 @@ Guidance for Claude Code in this repo. Follow it exactly — it overrides defaul - Pin a concrete simulator **UDID** — `name=iPhone 16` alone is ambiguous across runtimes (`xcrun simctl list devices`). - **Parallelization is disabled** (`InterlinedList.xctestplan`, `parallelizable:false`): the E2E suite shares a static login token that parallel cloned sims break. `-parallel-testing-enabled NO` is reinforcement; keep the plan setting in sync. +- **Parallel worktrees must not share a simulator or DerivedData.** Two threads testing at once on the + same UDID kill each other's runner mid-suite; the survivor reports a bogus failure and may even run + the *other* worktree's test bundle. Give each worktree its own `-destination id=` **and** + `-derivedDataPath`. - Unit tests stub HTTP via `MockURLSession` (`stub`/`enqueue`) — no network. - E2E tests (`InterlinedListTests/E2E`) hit the **live** API, **read-only**; auto-`XCTSkip` unless `INTERLINEDLIST_EMAIL`/`INTERLINEDLIST_PASSWORD` are set (process env or a gitignored `.env`). Network-flaky — for a deterministic run add `-skip-testing:InterlinedListTests/E2EReadOnlyTests`. - CI (`.github/workflows/ios.yml`) **builds only** (no tests) on push/PR to `main`, signing disabled. diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 625bfab..9f58419 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -47,12 +47,14 @@ 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; }; + 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; }; 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; }; 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; }; + 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */; }; 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; }; 99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */; }; 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.swift */; }; @@ -98,6 +100,7 @@ B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */; }; B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000E /* ListItemFormView.swift */; }; B270170D3EEF336A45464D01 /* CrossPostLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164718CC67967AC147E30465 /* CrossPostLinksView.swift */; }; + C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A026223AD393B5E1A339E045 /* APIClient+Limits.swift */; }; C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7800699C927CE3E133944E62 /* ReportSheet.swift */; }; @@ -123,12 +126,14 @@ D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; }; D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; }; DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */; }; + DB762CA98AEE01D098940CED /* APIClientLimitsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */; }; DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */; }; DDFD75240429AC8C22B4A828 /* APIClient+SharedLists.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */; }; DF75ECCB31B861F938E216A1 /* APIClientTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */; }; E1642E8329DC145DA96584D4 /* LinkedInTargetModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */; }; E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */; }; E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */; }; + E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0221B233F89B966D044C37FA /* ServerLimits.swift */; }; E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */; }; EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */; }; EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5569B56DBD6747BB9587870 /* SessionsView.swift */; }; @@ -190,6 +195,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0221B233F89B966D044C37FA /* ServerLimits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimits.swift; sourceTree = ""; }; 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = ""; }; 03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = ""; }; 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = ""; }; @@ -230,6 +236,7 @@ 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessor.swift; sourceTree = ""; }; 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutbox.swift; sourceTree = ""; }; 644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientMessageByIdTests.swift; sourceTree = ""; }; + 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimitsStore.swift; sourceTree = ""; }; 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientModerationTests.swift; sourceTree = ""; }; 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = ""; }; 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; @@ -247,8 +254,10 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = ""; }; 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = ""; }; + 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedListsTests.swift; sourceTree = ""; }; + A026223AD393B5E1A339E045 /* APIClient+Limits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Limits.swift"; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B1C1D1E1F10002 /* InterlinedListApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListApp.swift; sourceTree = ""; }; A1B1C1D1E1F10004 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -310,6 +319,7 @@ D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationBanner.swift; sourceTree = ""; }; D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentitiesView.swift; sourceTree = ""; }; D0A1D0A1D0A1000E /* OAuthSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSignInButton.swift; sourceTree = ""; }; + D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimitsStoreTests.swift; sourceTree = ""; }; D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutboxTests.swift; sourceTree = ""; }; D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = ""; }; D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+SharedLists.swift"; sourceTree = ""; }; @@ -453,6 +463,7 @@ 236670B73F2B603E5EB318D7 /* ShareInvite.swift */, B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */, C470172F5310397C2E6195BC /* SharedResolution.swift */, + 0221B233F89B966D044C37FA /* ServerLimits.swift */, ); path = Models; sourceTree = ""; @@ -478,6 +489,8 @@ BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */, 256FE9818A171E11DDE43140 /* APIClientTransport.swift */, D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */, + A026223AD393B5E1A339E045 /* APIClient+Limits.swift */, + 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, ); path = Services; sourceTree = ""; @@ -599,6 +612,7 @@ 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */, 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */, 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */, + 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -641,6 +655,7 @@ 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, + D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -839,6 +854,9 @@ 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */, 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */, DDFD75240429AC8C22B4A828 /* APIClient+SharedLists.swift in Sources */, + E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */, + C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */, + 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -918,6 +936,8 @@ 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */, 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */, 4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */, + DB762CA98AEE01D098940CED /* APIClientLimitsTests.swift in Sources */, + 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/ServerLimits.swift b/InterlinedList/Models/ServerLimits.swift new file mode 100644 index 0000000..19f3617 --- /dev/null +++ b/InterlinedList/Models/ServerLimits.swift @@ -0,0 +1,66 @@ +// +// ServerLimits.swift +// InterlinedList +// + +import CoreGraphics +import Foundation + +/// The upload and content caps the backend enforces, from `GET /api/limits` +/// (public, no auth). Reading them rather than hardcoding matters most for +/// images: the server resizes every upload to `image.maxPixels` per side +/// regardless of what the client sends, so anything larger is wasted bytes on +/// the wire and a slower post. +struct ServerLimits: Codable, Equatable, Sendable { + let media: Media + let message: MessageLimits? + + struct Media: Codable, Equatable, Sendable { + let image: ImageLimits + let video: VideoLimits? + } + + struct ImageLimits: Codable, Equatable, Sendable { + let maxBytes: Int + let maxPixels: Int + let acceptedFormats: [String]? + } + + struct VideoLimits: Codable, Equatable, Sendable { + let maxBytes: Int + let acceptedFormats: [String]? + } + + struct MessageLimits: Codable, Equatable, Sendable { + let maxContentLength: Int? + } + + /// Used until `GET /api/limits` answers, and if it never does. These are the + /// values the deployed backend reports today, so a failed fetch costs + /// accuracy only if the server's caps change. + static let fallback = ServerLimits( + media: Media( + image: ImageLimits(maxBytes: 1_400_000, maxPixels: 1200, acceptedFormats: nil), + video: VideoLimits(maxBytes: 3_145_728, acceptedFormats: nil) + ), + message: MessageLimits(maxContentLength: 5000) + ) +} + +/// The subset `ImageUploadProcessor` needs, in the units it works in. +struct ImageUploadLimits: Equatable, Sendable { + let maxUploadBytes: Int + let maxPixels: CGFloat + + init(maxUploadBytes: Int, maxPixels: CGFloat) { + self.maxUploadBytes = maxUploadBytes + self.maxPixels = maxPixels + } + + init(_ limits: ServerLimits) { + self.init(maxUploadBytes: limits.media.image.maxBytes, + maxPixels: CGFloat(limits.media.image.maxPixels)) + } + + static let fallback = ImageUploadLimits(ServerLimits.fallback) +} diff --git a/InterlinedList/Services/APIClient+Limits.swift b/InterlinedList/Services/APIClient+Limits.swift new file mode 100644 index 0000000..378183a --- /dev/null +++ b/InterlinedList/Services/APIClient+Limits.swift @@ -0,0 +1,15 @@ +// +// APIClient+Limits.swift +// InterlinedList +// + +import Foundation + +extension APIClient { + + /// `GET /api/limits` — the upload and content caps the backend enforces. + /// Public: it takes no auth and is safe to call before sign-in. + func serverLimits() async throws -> ServerLimits { + try await get("/api/limits") + } +} diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index af7d760..589c422 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -902,6 +902,12 @@ final class APIClient { let _: OkResponse = try await patch("/api/notifications/\(encoded)/read", body: Empty()) } + /// `DELETE /api/notifications/:id` — removes one notification for good + /// (answers 204, and 404 if it isn't the caller's). + func deleteNotification(id: String) async throws { + try await delete("/api/notifications/\(pathSegment(id))") + } + func markAllNotificationsRead() async throws { struct Empty: Encodable {} struct OkResponse: Decodable { let ok: Bool; let updated: Int? } diff --git a/InterlinedList/Services/ImageUploadProcessor.swift b/InterlinedList/Services/ImageUploadProcessor.swift index ecab857..ff75990 100644 --- a/InterlinedList/Services/ImageUploadProcessor.swift +++ b/InterlinedList/Services/ImageUploadProcessor.swift @@ -9,34 +9,45 @@ import ImageIO import UniformTypeIdentifiers enum ImageUploadProcessor { - static let maxUploadBytes = 1_400_000 - static let maxDimension: CGFloat = 2048 - + /// Fallback rungs, largest first. The active ladder is derived from the + /// server's `maxPixels` cap, so anything above it is dropped — the backend + /// resizes every upload to that cap anyway, and sending more just costs the + /// user bytes and time. static let opaqueDimensionLadder: [CGFloat] = [2048, 1600, 1200, 1000, 800] static let opaqueQualityLadder: [CGFloat] = [0.85, 0.7, 0.55, 0.4] static let alphaDimensionLadder: [CGFloat] = [1600, 1200, 1000, 800, 600, 400] - static func process(_ inputData: Data) -> (data: Data, mimeType: String)? { + /// The server cap becomes the first rung; the standard rungs below it follow. + /// Rungs at or above the cap are pointless, so they're dropped. + static func dimensionLadder(_ base: [CGFloat], maxPixels: CGFloat) -> [CGFloat] { + [maxPixels] + base.filter { $0 < maxPixels } + } + + /// `limits` defaults to whatever `GET /api/limits` last reported (the + /// documented fallback until it answers). It is a parameter so the ladder + /// logic stays a pure function under test. + static func process(_ inputData: Data, + limits: ImageUploadLimits = ServerLimitsStore.shared.imageLimits) -> (data: Data, mimeType: String)? { guard let source = CGImageSourceCreateWithData(inputData as CFData, nil) else { return nil } - if let passthrough = passthroughIfAlreadySafe(source: source, inputData: inputData) { + if let passthrough = passthroughIfAlreadySafe(source: source, inputData: inputData, limits: limits) { return passthrough } - guard let image = downsampledImage(source: source, maxPixelSize: maxDimension) else { return nil } + guard let image = downsampledImage(source: source, maxPixelSize: limits.maxPixels) else { return nil } if isOpaque(image) { - return encodeOpaqueLadder(source: source, firstAttempt: image) + return encodeOpaqueLadder(source: source, firstAttempt: image, limits: limits) } - if let pngResult = encodeAlphaLadder(source: source) { + if let pngResult = encodeAlphaLadder(source: source, limits: limits) { return pngResult } - return encodeOpaqueLadder(source: source, firstAttempt: nil) + return encodeOpaqueLadder(source: source, firstAttempt: nil, limits: limits) } - private static func passthroughIfAlreadySafe(source: CGImageSource, inputData: Data) -> (data: Data, mimeType: String)? { + private static func passthroughIfAlreadySafe(source: CGImageSource, inputData: Data, limits: ImageUploadLimits) -> (data: Data, mimeType: String)? { guard let type = CGImageSourceGetType(source) as String? else { return nil } let mimeType: String if UTType(type) == .jpeg { @@ -47,13 +58,13 @@ enum ImageUploadProcessor { return nil } - guard inputData.count <= maxUploadBytes else { return nil } + guard inputData.count <= limits.maxUploadBytes else { return nil } guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let widthNumber = properties[kCGImagePropertyPixelWidth] as? NSNumber, let heightNumber = properties[kCGImagePropertyPixelHeight] as? NSNumber else { return nil } let width = widthNumber.doubleValue let height = heightNumber.doubleValue - guard width <= Double(maxDimension), height <= Double(maxDimension) else { return nil } + guard width <= Double(limits.maxPixels), height <= Double(limits.maxPixels) else { return nil } return (inputData, mimeType) } @@ -76,11 +87,11 @@ enum ImageUploadProcessor { } } - private static func encodeOpaqueLadder(source: CGImageSource, firstAttempt: CGImage?) -> (data: Data, mimeType: String)? { + private static func encodeOpaqueLadder(source: CGImageSource, firstAttempt: CGImage?, limits: ImageUploadLimits) -> (data: Data, mimeType: String)? { var smallest: Data? var isFirstDimension = true - for dimension in opaqueDimensionLadder { + for dimension in dimensionLadder(opaqueDimensionLadder, maxPixels: limits.maxPixels) { let image: CGImage? if isFirstDimension, let firstAttempt { image = firstAttempt @@ -96,7 +107,7 @@ enum ImageUploadProcessor { if smallest == nil || data.count < (smallest?.count ?? Int.max) { smallest = data } - if data.count <= maxUploadBytes { + if data.count <= limits.maxUploadBytes { return (data, "image/jpeg") } } @@ -106,11 +117,11 @@ enum ImageUploadProcessor { return (smallest, "image/jpeg") } - private static func encodeAlphaLadder(source: CGImageSource) -> (data: Data, mimeType: String)? { - for dimension in alphaDimensionLadder { + private static func encodeAlphaLadder(source: CGImageSource, limits: ImageUploadLimits) -> (data: Data, mimeType: String)? { + for dimension in dimensionLadder(alphaDimensionLadder, maxPixels: limits.maxPixels) { guard let image = downsampledImage(source: source, maxPixelSize: dimension) else { continue } guard let data = pngData(from: image) else { continue } - if data.count <= maxUploadBytes { + if data.count <= limits.maxUploadBytes { return (data, "image/png") } } diff --git a/InterlinedList/Services/ServerLimitsStore.swift b/InterlinedList/Services/ServerLimitsStore.swift new file mode 100644 index 0000000..2b40bad --- /dev/null +++ b/InterlinedList/Services/ServerLimitsStore.swift @@ -0,0 +1,40 @@ +// +// ServerLimitsStore.swift +// InterlinedList +// + +import Foundation + +/// Holds the server's upload caps for the lifetime of the app, refreshed once at +/// launch. +/// +/// Not an `ObservableObject`: its only consumer is `ImageUploadProcessor`, which +/// runs on a detached task off the main actor, so this has to be readable from +/// any thread rather than isolated to one. An `NSLock` around a single value is +/// the whole implementation. +final class ServerLimitsStore: @unchecked Sendable { + static let shared = ServerLimitsStore() + + private let lock = NSLock() + private var storedLimits: ServerLimits = .fallback + private let fetch: @Sendable () async throws -> ServerLimits + + init(fetch: @escaping @Sendable () async throws -> ServerLimits = { try await APIClient.shared.serverLimits() }) { + self.fetch = fetch + } + + var limits: ServerLimits { + lock.withLock { storedLimits } + } + + var imageLimits: ImageUploadLimits { + ImageUploadLimits(limits) + } + + /// Best-effort: on failure the fallback caps stay in place, which match what + /// the backend enforces today, so an upload is never blocked by this. + func refresh() async { + guard let fetched = try? await fetch() else { return } + lock.withLock { storedLimits = fetched } + } +} diff --git a/InterlinedList/Views/NotificationsView.swift b/InterlinedList/Views/NotificationsView.swift index 500d59b..4aa5099 100644 --- a/InterlinedList/Views/NotificationsView.swift +++ b/InterlinedList/Views/NotificationsView.swift @@ -14,6 +14,7 @@ struct NotificationsView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var selectedNotification: AppNotification? + @State private var deleteError: String? var body: some View { NavigationStack { @@ -55,6 +56,14 @@ struct NotificationsView: View { selectedNotification = notification Task { await markRead(notification) } } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { await deleteNotification(notification) } + } label: { + Label("Delete", systemImage: "trash") + } + .accessibilityLabel("Delete notification") + } } } } @@ -81,6 +90,14 @@ struct NotificationsView: View { .sheet(item: $selectedNotification) { notification in NotificationDetailView(notification: notification) } + .alert("Couldn't delete", isPresented: Binding( + get: { deleteError != nil }, + set: { if !$0 { deleteError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(deleteError ?? "") + } } } @@ -132,6 +149,22 @@ struct NotificationsView: View { } catch {} } + /// Removed optimistically: the row disappears on swipe and is restored only + /// if the server rejects the delete, so the gesture never feels laggy. + private func deleteNotification(_ notification: AppNotification) async { + let removed = notifications + notifications.removeAll { $0.id == notification.id } + do { + try await APIClient.shared.deleteNotification(id: notification.id) + } catch APIError.status(401) { + notifications = removed + authState.handleUnauthorized() + } catch { + notifications = removed + deleteError = "That notification couldn't be deleted. Please try again." + } + } + private func markAllRead() async { do { try await APIClient.shared.markAllNotificationsRead() diff --git a/InterlinedList/Views/RootView.swift b/InterlinedList/Views/RootView.swift index e1311ce..85a5d44 100644 --- a/InterlinedList/Views/RootView.swift +++ b/InterlinedList/Views/RootView.swift @@ -17,6 +17,11 @@ struct RootView: View { } } .preferredColorScheme(preferredScheme) + // The server resizes every image upload to its own cap regardless of + // what the client sends, so read the caps once at launch and let + // ImageUploadProcessor size to them. Public route — no token needed, + // and a failure just leaves the documented fallbacks in place. + .task { await ServerLimitsStore.shared.refresh() } } /// Honor the user's saved theme preference ("light" / "dark"); "system" or diff --git a/InterlinedListTests/APIClientTests/APIClientLimitsTests.swift b/InterlinedListTests/APIClientTests/APIClientLimitsTests.swift new file mode 100644 index 0000000..197bc9e --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientLimitsTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import InterlinedList + +/// P3. iOS hardcoded a 2048px image ladder while the backend resizes every +/// upload to 1200px per side, so every post shipped roughly 2.9x the pixels the +/// server kept. These tests pin the route and the decode of the caps that now +/// drive `ImageUploadProcessor`. +final class APIClientLimitsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + } + + /// The live payload, verbatim. + private var limitsJSON: String { + #""" + {"media":{"image":{"maxBytes":1468006,"maxPixels":1200, + "acceptedFormats":["jpeg","png","gif","webp"]}, + "video":{"maxBytes":3145728,"acceptedFormats":["mp4","mov"]}}, + "message":{"maxContentLength":5000}} + """# + } + + func test_serverLimits_sendsGetToLimitsPath() async throws { + session.stub(json: limitsJSON) + _ = try await sut.serverLimits() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/limits") + } + + /// Public route — it must work before sign-in, with no token set. + func test_serverLimits_worksWithoutABearerToken() async throws { + session.stub(json: limitsJSON) + let limits = try await sut.serverLimits() + XCTAssertNil(session.lastRequest?.value(forHTTPHeaderField: "Authorization")) + XCTAssertEqual(limits.media.image.maxPixels, 1200) + } + + func test_serverLimits_decodesImageCaps() async throws { + session.stub(json: limitsJSON) + let limits = try await sut.serverLimits() + XCTAssertEqual(limits.media.image.maxBytes, 1_468_006) + XCTAssertEqual(limits.media.image.maxPixels, 1200) + XCTAssertEqual(limits.media.image.acceptedFormats, ["jpeg", "png", "gif", "webp"]) + } + + func test_serverLimits_decodesVideoAndMessageCaps() async throws { + session.stub(json: limitsJSON) + let limits = try await sut.serverLimits() + XCTAssertEqual(limits.media.video?.maxBytes, 3_145_728) + XCTAssertEqual(limits.message?.maxContentLength, 5000) + } + + /// Only the image block is required; a payload without the optional sections + /// must still decode rather than falling back wholesale. + func test_serverLimits_decodesWithoutOptionalSections() async throws { + session.stub(json: #"{"media":{"image":{"maxBytes":1000000,"maxPixels":800}}}"#) + let limits = try await sut.serverLimits() + XCTAssertEqual(limits.media.image.maxPixels, 800) + XCTAssertNil(limits.media.video) + XCTAssertNil(limits.message) + } + + func test_imageUploadLimits_areDerivedFromTheServerPayload() async throws { + session.stub(json: limitsJSON) + let converted = ImageUploadLimits(try await sut.serverLimits()) + XCTAssertEqual(converted.maxUploadBytes, 1_468_006) + XCTAssertEqual(converted.maxPixels, 1200) + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift b/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift index d545039..229e609 100644 --- a/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift @@ -63,4 +63,52 @@ final class APIClientNotificationsTests: XCTestCase { XCTAssertEqual(session.lastRequest?.httpMethod, "POST") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/notifications/mark-all-read") == true) } + + // MARK: deleteNotification() — P1 + + /// The route answers 204 with an empty body, so nothing may be decoded. + func test_deleteNotification_sendsDeleteToCorrectPath() async throws { + session.stub(data: Data(), statusCode: 204) + try await sut.deleteNotification(id: "n1") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/notifications/n1") == true) + } + + func test_deleteNotification_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 204) + try await sut.deleteNotification(id: "n1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_deleteNotification_percentEncodesId() async throws { + session.stub(data: Data(), statusCode: 204) + try await sut.deleteNotification(id: "n 1") + XCTAssertEqual(session.lastRequest?.url?.absoluteString.hasSuffix("/api/notifications/n%201"), true) + } + + /// Someone else's notification — the view keeps the row removed rather than + /// restoring it, so the error still has to surface as a 404. + func test_deleteNotification_404_throws() async { + session.stub(json: #"{"error":"Not found"}"#, statusCode: 404) + do { + try await sut.deleteNotification(id: "n1") + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "Not found") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_deleteNotification_401_throws() async { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.deleteNotification(id: "n1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } catch { + XCTFail("Unexpected error: \(error)") + } + } } diff --git a/InterlinedListTests/ServiceTests/ImageUploadProcessorTests.swift b/InterlinedListTests/ServiceTests/ImageUploadProcessorTests.swift index 2c6b54e..5068aff 100644 --- a/InterlinedListTests/ServiceTests/ImageUploadProcessorTests.swift +++ b/InterlinedListTests/ServiceTests/ImageUploadProcessorTests.swift @@ -6,28 +6,81 @@ import XCTest final class ImageUploadProcessorTests: XCTestCase { + /// Every `process` call under test pins its own caps rather than reading + /// `ServerLimitsStore.shared`, so results don't depend on whether + /// `GET /api/limits` happened to answer. + private let limits = ImageUploadLimits.fallback + // MARK: - Ladder constants regression guard func test_ladderConstants_matchSpecifiedValues() { - XCTAssertEqual(ImageUploadProcessor.maxUploadBytes, 1_400_000) - XCTAssertEqual(ImageUploadProcessor.maxDimension, 2048) XCTAssertEqual(ImageUploadProcessor.opaqueDimensionLadder, [2048, 1600, 1200, 1000, 800]) XCTAssertEqual(ImageUploadProcessor.opaqueQualityLadder, [0.85, 0.7, 0.55, 0.4]) XCTAssertEqual(ImageUploadProcessor.alphaDimensionLadder, [1600, 1200, 1000, 800, 600, 400]) } + // MARK: - Ladder derived from the server cap (P3) + + /// The deployed backend resizes to 1200px per side, so the ladder must start + /// there rather than at the old hardcoded 2048 — sending 2048px cost every + /// upload ~2.9x the pixels the server keeps. + func test_dimensionLadder_startsAtServerCap_andDropsLargerRungs() { + let ladder = ImageUploadProcessor.dimensionLadder(ImageUploadProcessor.opaqueDimensionLadder, maxPixels: 1200) + XCTAssertEqual(ladder, [1200, 1000, 800]) + } + + func test_dimensionLadder_capAboveEveryRung_keepsFullLadder() { + let ladder = ImageUploadProcessor.dimensionLadder(ImageUploadProcessor.opaqueDimensionLadder, maxPixels: 4096) + XCTAssertEqual(ladder, [4096, 2048, 1600, 1200, 1000, 800]) + } + + /// A cap that lands between rungs still leads the ladder, so the first + /// attempt is always exactly what the server accepts. + func test_dimensionLadder_capBetweenRungs_leadsTheLadder() { + let ladder = ImageUploadProcessor.dimensionLadder(ImageUploadProcessor.opaqueDimensionLadder, maxPixels: 900) + XCTAssertEqual(ladder, [900, 800]) + } + + func test_dimensionLadder_appliesToTheAlphaLadderToo() { + let ladder = ImageUploadProcessor.dimensionLadder(ImageUploadProcessor.alphaDimensionLadder, maxPixels: 1200) + XCTAssertEqual(ladder, [1200, 1000, 800, 600, 400]) + } + + func test_defaultLimits_matchTheDeployedServerCaps() { + XCTAssertEqual(ImageUploadLimits.fallback.maxPixels, 1200) + XCTAssertEqual(ImageUploadLimits.fallback.maxUploadBytes, 1_400_000) + } + + /// The point of P3: a 4000px photo comes out at the server's cap, not above. + func test_process_largeImage_isSizedToTheServerCap_notTheOldClientMax() throws { + let large = try XCTUnwrap(Self.makeImageData(width: 4000, height: 3000, format: .png, opaque: true)) + let result = try XCTUnwrap(ImageUploadProcessor.process(large, limits: limits)) + let (width, height) = try XCTUnwrap(Self.dimensions(of: result.data)) + XCTAssertLessThanOrEqual(max(width, height), 1200) + } + + /// A raised server cap is honoured without a client release. + func test_process_respectsARaisedServerCap() throws { + let large = try XCTUnwrap(Self.makeImageData(width: 4000, height: 3000, format: .png, opaque: true)) + let raised = ImageUploadLimits(maxUploadBytes: 1_400_000, maxPixels: 2048) + let result = try XCTUnwrap(ImageUploadProcessor.process(large, limits: raised)) + let (width, height) = try XCTUnwrap(Self.dimensions(of: result.data)) + XCTAssertLessThanOrEqual(max(width, height), 2048) + XCTAssertGreaterThan(max(width, height), 1200, "A raised cap should produce a larger image than the default") + } + // MARK: - Passthrough fast path func test_process_smallJPEGUnderBudget_passesThroughUnchanged() throws { let jpeg = try XCTUnwrap(Self.makeImageData(width: 100, height: 100, format: .jpeg, opaque: true)) - let result = try XCTUnwrap(ImageUploadProcessor.process(jpeg)) + let result = try XCTUnwrap(ImageUploadProcessor.process(jpeg, limits: limits)) XCTAssertEqual(result.data, jpeg) XCTAssertEqual(result.mimeType, "image/jpeg") } func test_process_smallPNGUnderBudget_passesThroughUnchanged() throws { let png = try XCTUnwrap(Self.makeImageData(width: 100, height: 100, format: .png, opaque: true)) - let result = try XCTUnwrap(ImageUploadProcessor.process(png)) + let result = try XCTUnwrap(ImageUploadProcessor.process(png, limits: limits)) XCTAssertEqual(result.data, png) XCTAssertEqual(result.mimeType, "image/png") } @@ -36,7 +89,7 @@ final class ImageUploadProcessorTests: XCTestCase { func test_process_heicInput_neverPassesThrough_alwaysConvertsToJPEG() throws { let heic = try XCTUnwrap(Self.makeImageData(width: 100, height: 100, format: .heic, opaque: true)) - let result = try XCTUnwrap(ImageUploadProcessor.process(heic)) + let result = try XCTUnwrap(ImageUploadProcessor.process(heic, limits: limits)) XCTAssertNotEqual(result.data, heic) XCTAssertEqual(result.mimeType, "image/jpeg") @@ -49,21 +102,21 @@ final class ImageUploadProcessorTests: XCTestCase { func test_process_largeOpaqueImage_downsamplesUnderBudgetAsJPEG() throws { let large = try XCTUnwrap(Self.makeImageData(width: 4000, height: 3000, format: .png, opaque: true)) - let result = try XCTUnwrap(ImageUploadProcessor.process(large)) + let result = try XCTUnwrap(ImageUploadProcessor.process(large, limits: limits)) XCTAssertEqual(result.mimeType, "image/jpeg") - XCTAssertLessThanOrEqual(result.data.count, ImageUploadProcessor.maxUploadBytes) + XCTAssertLessThanOrEqual(result.data.count, limits.maxUploadBytes) let (width, height) = try XCTUnwrap(Self.dimensions(of: result.data)) - XCTAssertLessThanOrEqual(max(width, height), ImageUploadProcessor.maxDimension) + XCTAssertLessThanOrEqual(max(width, height), limits.maxPixels) } // MARK: - Large alpha image preserved as PNG func test_process_largeAlphaImage_preservesPNGWithAlpha() throws { let large = try XCTUnwrap(Self.makeImageData(width: 3000, height: 2000, format: .png, opaque: false)) - let result = try XCTUnwrap(ImageUploadProcessor.process(large)) + let result = try XCTUnwrap(ImageUploadProcessor.process(large, limits: limits)) XCTAssertEqual(result.mimeType, "image/png") - XCTAssertLessThanOrEqual(result.data.count, ImageUploadProcessor.maxUploadBytes) + XCTAssertLessThanOrEqual(result.data.count, limits.maxUploadBytes) let outputSource = try XCTUnwrap(CGImageSourceCreateWithData(result.data as CFData, nil)) let outputImage = try XCTUnwrap(CGImageSourceCreateImageAtIndex(outputSource, 0, nil)) @@ -86,20 +139,20 @@ final class ImageUploadProcessorTests: XCTestCase { // proving the ladder correctly walks down to a fitting rung rather than stopping early. func test_process_denseNoiseAlphaImage_stillResolvesToPNGUnderBudget() throws { let noisy = try XCTUnwrap(Self.makeNoiseImageData(width: 2200, height: 2200)) - let result = try XCTUnwrap(ImageUploadProcessor.process(noisy)) + let result = try XCTUnwrap(ImageUploadProcessor.process(noisy, limits: limits)) XCTAssertEqual(result.mimeType, "image/png") - XCTAssertLessThanOrEqual(result.data.count, ImageUploadProcessor.maxUploadBytes) + XCTAssertLessThanOrEqual(result.data.count, limits.maxUploadBytes) } // MARK: - Corrupt input func test_process_corruptInput_returnsNil() { let garbage = Data([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE]) - XCTAssertNil(ImageUploadProcessor.process(garbage)) + XCTAssertNil(ImageUploadProcessor.process(garbage, limits: limits)) } func test_process_emptyInput_returnsNil() { - XCTAssertNil(ImageUploadProcessor.process(Data())) + XCTAssertNil(ImageUploadProcessor.process(Data(), limits: limits)) } // MARK: - Over-budget-floor case returns smallest attempt, not nil @@ -113,7 +166,7 @@ final class ImageUploadProcessorTests: XCTestCase { // that guarantee, even though the true fallback branch can't be forced to execute. func test_process_worstCaseOpaqueNoiseImage_returnsNonNilResult() throws { let noisy = try XCTUnwrap(Self.makeImageData(width: 4000, height: 4000, format: .png, opaque: true)) - let result = ImageUploadProcessor.process(noisy) + let result = ImageUploadProcessor.process(noisy, limits: limits) XCTAssertNotNil(result, "process(_:) must never return nil for decodable input, even in a worst-case compression scenario") } diff --git a/InterlinedListTests/ServiceTests/ServerLimitsStoreTests.swift b/InterlinedListTests/ServiceTests/ServerLimitsStoreTests.swift new file mode 100644 index 0000000..7cd2673 --- /dev/null +++ b/InterlinedListTests/ServiceTests/ServerLimitsStoreTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import InterlinedList + +/// P3. The store is what stands between a failed `GET /api/limits` and a broken +/// upload: it must always hand back usable caps. +final class ServerLimitsStoreTests: XCTestCase { + + private func makeLimits(maxBytes: Int, maxPixels: Int) -> ServerLimits { + ServerLimits( + media: .init(image: .init(maxBytes: maxBytes, maxPixels: maxPixels, acceptedFormats: nil), + video: nil), + message: nil + ) + } + + func test_limits_beforeAnyRefresh_areTheDocumentedFallback() { + let sut = ServerLimitsStore(fetch: { XCTFail("Should not fetch"); return .fallback }) + XCTAssertEqual(sut.limits, ServerLimits.fallback) + XCTAssertEqual(sut.imageLimits.maxPixels, 1200) + XCTAssertEqual(sut.imageLimits.maxUploadBytes, 1_400_000) + } + + func test_refresh_adoptsTheServerCaps() async { + let fetched = makeLimits(maxBytes: 2_000_000, maxPixels: 1600) + let sut = ServerLimitsStore(fetch: { fetched }) + await sut.refresh() + XCTAssertEqual(sut.limits, fetched) + XCTAssertEqual(sut.imageLimits.maxPixels, 1600) + XCTAssertEqual(sut.imageLimits.maxUploadBytes, 2_000_000) + } + + /// A network failure must never block an upload — the fallback caps stand. + func test_refresh_failure_keepsTheFallback() async { + struct Boom: Error {} + let sut = ServerLimitsStore(fetch: { throw Boom() }) + await sut.refresh() + XCTAssertEqual(sut.limits, ServerLimits.fallback) + } + + /// A later failure must not discard caps already fetched successfully. + func test_refresh_failureAfterSuccess_keepsTheFetchedCaps() async { + struct Boom: Error {} + let fetched = makeLimits(maxBytes: 2_000_000, maxPixels: 1600) + let succeeding = ServerLimitsStore(fetch: { fetched }) + await succeeding.refresh() + XCTAssertEqual(succeeding.imageLimits.maxPixels, 1600) + + let failing = ServerLimitsStore(fetch: { throw Boom() }) + await failing.refresh() + XCTAssertEqual(failing.imageLimits.maxPixels, 1200) + } + + /// The store is read from a detached task inside `ImageUploadProcessor`, so + /// concurrent reads during a refresh must not trip the lock or tear a value. + func test_limits_areSafeToReadConcurrently() async { + let fetched = makeLimits(maxBytes: 2_000_000, maxPixels: 1600) + let sut = ServerLimitsStore(fetch: { fetched }) + await withTaskGroup(of: Void.self) { group in + group.addTask { await sut.refresh() } + for _ in 0..<50 { + group.addTask { + let pixels = sut.imageLimits.maxPixels + XCTAssertTrue(pixels == 1200 || pixels == 1600, "Read a torn value: \(pixels)") + } + } + } + XCTAssertEqual(sut.imageLimits.maxPixels, 1600) + } +}