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