diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f6d3089..cf976b2 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; }; 4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */; }; 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; }; + 4CE14BC5E23958D0C0C275D6 /* APIClientSingleListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */; }; 54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; }; 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; }; @@ -110,6 +111,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 */; }; + BCA5EFBE30EB9AB46C884724 /* APIClient+Lists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8C417F18CC7C89053F3D7E /* APIClient+Lists.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 */; }; @@ -135,6 +137,7 @@ D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */; }; D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000E /* OAuthSignInButton.swift */; }; D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */; }; + D3560D56DEA6AA150B284A14 /* ListLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F00D95CBB550904B2C5298 /* ListLinkView.swift */; }; D51B991E570C29EC9D0FD3A4 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A39A4CB7CD21E155C1AD77 /* AIService.swift */; }; D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; }; D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; }; @@ -223,10 +226,12 @@ 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = ""; }; 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = ""; }; 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceError.swift; sourceTree = ""; }; + 0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSingleListTests.swift; sourceTree = ""; }; 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkedInTargetsTests.swift; sourceTree = ""; }; 0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = ""; }; 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIClientGitHubTests.swift; path = APIClientGitHubTests.swift; sourceTree = ""; }; + 10F00D95CBB550904B2C5298 /* ListLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListLinkView.swift; sourceTree = ""; }; 1147B978D9E72695E4457454 /* Moderation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Moderation.swift; sourceTree = ""; }; 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflictTests.swift; sourceTree = ""; }; 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTagsTests.swift; sourceTree = ""; }; @@ -270,6 +275,7 @@ 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = ""; }; 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 7B8C417F18CC7C89053F3D7E /* APIClient+Lists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Lists.swift"; sourceTree = ""; }; 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = ""; }; 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; 7F919661375CA53C1864025A /* AIArtifact.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifact.swift; sourceTree = ""; }; @@ -540,6 +546,7 @@ 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, + 7B8C417F18CC7C89053F3D7E /* APIClient+Lists.swift */, ); path = Services; sourceTree = ""; @@ -607,6 +614,7 @@ 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */, 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */, B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */, + 10F00D95CBB550904B2C5298 /* ListLinkView.swift */, ); path = Views; sourceTree = ""; @@ -669,6 +677,7 @@ 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */, 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */, + 0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -932,6 +941,8 @@ FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */, 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */, E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, + BCA5EFBE30EB9AB46C884724 /* APIClient+Lists.swift in Sources */, + D3560D56DEA6AA150B284A14 /* ListLinkView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1018,6 +1029,7 @@ 016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */, 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */, C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */, + 4CE14BC5E23958D0C0C275D6 /* APIClientSingleListTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 2ac1f9e..029a1c0 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -69,6 +69,22 @@ struct InterlinedListApp: App { case .document(let id): DocumentLinkView(documentId: id) .environmentObject(authState) + case .list(let id): + ListLinkView(listId: id) + .environmentObject(authState) + .environmentObject(store) + case .publicList(let owner, let id): + NavigationStack { + PublicListDetailView(username: owner, listId: id) + .toolbar { deepLinkDoneButton } + } + .environmentObject(authState) + case .publicDocument(_, let id): + NavigationStack { + PublicDocumentReader(documentId: id, title: "Document") + .toolbar { deepLinkDoneButton } + } + .environmentObject(authState) case .sharedDocument(let token): SharedDocumentView(token: token) .environmentObject(authState) @@ -82,6 +98,15 @@ struct InterlinedListApp: App { } } + /// The public list/document readers are built to be *pushed*, so presenting one + /// from a deep link has to supply its own way back out. + @ToolbarContentBuilder + private var deepLinkDoneButton: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { router.pendingDeepLink = nil } + } + } + // A2: Universal Links (a tapped https://interlinedlist.com link opening the app // directly). The Associated Domains entitlement (applinks:interlinedlist.com) is now // in place and `parse` already accepts https permalinks, so onOpenURL will route them @@ -101,7 +126,8 @@ struct InterlinedListApp: App { Task { await verifyEmail(token: token) } case .verifyEmailChange(let token): Task { await verifyEmailChange(token: token) } - case .resetPassword, .userProfile, .message, .document, .sharedDocument, .sharedList: + case .resetPassword, .userProfile, .message, .document, .list, + .publicList, .publicDocument, .sharedDocument, .sharedList: router.pendingDeepLink = link } } @@ -154,6 +180,11 @@ enum AppDeepLink: Identifiable, Hashable { case userProfile(username: String) case message(id: String) case document(id: String) + case list(id: String) + /// Owner-scoped public permalinks (`/user//lists|documents/`) — + /// the links the web shows when a list or document is made public. + case publicList(owner: String, id: String) + case publicDocument(owner: String, id: String) case sharedDocument(token: String) case sharedList(token: String) @@ -165,6 +196,9 @@ enum AppDeepLink: Identifiable, Hashable { case .userProfile(let username): return "profile:" + username case .message(let id): return "message:" + id case .document(let id): return "document:" + id + case .list(let id): return "list:" + id + case .publicList(let owner, let id): return "public-list:" + owner + "/" + id + case .publicDocument(let owner, let id): return "public-document:" + owner + "/" + id case .sharedDocument(let token): return "shared-document:" + token case .sharedList(let token): return "shared-list:" + token } @@ -172,9 +206,10 @@ enum AppDeepLink: Identifiable, Hashable { /// Parses both the custom scheme (`interlinedlist://…`, where the target is the /// URL host or first path segment) and canonical web permalinks - /// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`). Content - /// links map to `.userProfile` / `.message`; auth links preserve their `?token`. - /// Returns nil for unknown targets or web hosts other than interlinedlist.com. + /// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`), including + /// the owner-scoped shapes the web hands out (`/user//status/` is the + /// canonical *message* permalink, not a profile link); auth links preserve their + /// `?token`. Returns nil for unknown targets or hosts other than interlinedlist.com. static func parse(_ url: URL) -> AppDeepLink? { let scheme = url.scheme?.lowercased() let host = url.host?.lowercased() ?? "" @@ -207,6 +242,16 @@ enum AppDeepLink: Identifiable, Hashable { switch target { case "user": guard let username = segments.first, !username.isEmpty else { return nil } + // The web scopes its canonical permalinks to the author's profile, so the + // second segment — not the first — decides what a /user/… link opens. + if segments.count >= 3, !segments[2].isEmpty { + switch segments[1] { + case "status": return .message(id: segments[2]) + case "lists": return .publicList(owner: username, id: segments[2]) + case "documents": return .publicDocument(owner: username, id: segments[2]) + default: break + } + } return .userProfile(username: username) case "message": guard let id = segments.first, !id.isEmpty else { return nil } @@ -222,11 +267,14 @@ enum AppDeepLink: Identifiable, Hashable { return .document(id: id) case "lists": // `/lists/shared/` resolves a self-contained share-link token - // (read-only viewer). A bare `/lists/` permalink is NOT routed — it - // carries no owner username, which the list-detail endpoint requires - // (see the-gaps.md); only the shared-token form is handled here. - guard segments.first == "shared", segments.count >= 2, !segments[1].isEmpty else { return nil } - return .sharedList(token: segments[1]) + // (read-only viewer); a bare `/lists/` is the authed permalink the + // app itself hands out (`ILWebURL.list(_:)`), routed to the list detail. + if segments.first == "shared" { + guard segments.count >= 2, !segments[1].isEmpty else { return nil } + return .sharedList(token: segments[1]) + } + guard let id = segments.first, !id.isEmpty else { return nil } + return .list(id: id) case "reset-password": guard let token, !token.isEmpty else { return nil } return .resetPassword(token: token) diff --git a/InterlinedList/Services/APIClient+Lists.swift b/InterlinedList/Services/APIClient+Lists.swift new file mode 100644 index 0000000..2219cfb --- /dev/null +++ b/InterlinedList/Services/APIClient+Lists.swift @@ -0,0 +1,33 @@ +// +// APIClient+Lists.swift +// InterlinedList +// + +import Foundation + +/// Why a list a permalink points at cannot be shown. +enum ListAccessError: Error { + /// The viewer holds no role on this list (403), or it does not exist / is + /// deleted (404). The backend deliberately answers both for a list the + /// viewer may not see, so they collapse into one user-facing state. + case noAccess +} + +extension APIClient { + /// A single list by id (`GET /api/lists/:id`). + /// + /// Bearer-ready, and authorized by *role* — owner / manager / collaborator / + /// watcher — so a bare `/lists/` permalink needs no owner username in the + /// URL to resolve. Throws `ListAccessError.noAccess` for 403/404. + func list(id: String) async throws -> UserList { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + struct Response: Decodable { + let data: UserList + } + let response: Response = try await get( + "/api/lists/\(encoded)", + mappingStatuses: [403: ListAccessError.noAccess, 404: ListAccessError.noAccess] + ) + return response.data + } +} diff --git a/InterlinedList/Services/APIClientTransport.swift b/InterlinedList/Services/APIClientTransport.swift index 4b0b286..43fb7ee 100644 --- a/InterlinedList/Services/APIClientTransport.swift +++ b/InterlinedList/Services/APIClientTransport.swift @@ -50,6 +50,25 @@ extension APIClient { return data } + /// GET that maps selected non-2xx statuses to caller-supplied errors *before* + /// the generic body-driven mapping runs. A route answering 403/404 with an + /// `{"error": …}` body otherwise arrives as `.forbidden`/`.server`, which + /// loses the distinction a permalink opener needs: "you have no access here" + /// reads very differently from "the server had a problem". + func get(_ path: String, mappingStatuses statusErrors: [Int: Error]) async throws -> T { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + authorize(&request) + let (data, response) = try await session.data(for: request) + if let status = (response as? HTTPURLResponse)?.statusCode, let mapped = statusErrors[status] { + throw mapped + } + try checkResponse(data: data, response: response) + return try decoder.decode(T.self, from: data) + } + // MARK: - Writes (snake_case bodies) func post(_ path: String, body: B, authenticated: Bool = true) async throws -> T { diff --git a/InterlinedList/Views/ListLinkView.swift b/InterlinedList/Views/ListLinkView.swift new file mode 100644 index 0000000..adb44af --- /dev/null +++ b/InterlinedList/Views/ListLinkView.swift @@ -0,0 +1,90 @@ +// +// ListLinkView.swift +// InterlinedList +// + +import SwiftUI + +/// Loader shown when a bare `/lists/` permalink opens — the link the app +/// itself hands out (`ILWebURL.list(_:)`). `ListDetailView` needs a whole +/// `UserList` but the link carries only an id, so this resolves the list from +/// the lists already in the store (owned, then shared-in) and falls back to +/// `GET /api/lists/:id`, which authorizes by role. Mirrors `MessageLinkView`. +struct ListLinkView: View { + let listId: String + + @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore + @Environment(\.dismiss) private var dismiss + @State private var list: UserList? + @State private var errorMessage: String? + @State private var hasNoAccess = false + @State private var isLoading = true + + var body: some View { + NavigationStack { + Group { + if let list { + ListDetailView(list: list) + .environmentObject(authState) + } else if isLoading { + ProgressView("Loading list…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if hasNoAccess { + ContentUnavailableView { + Label("No access to this list", systemImage: "lock") + } description: { + Text("You don't have access to this list. Ask its owner to share it with you.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityLabel("You don't have access to this list") + } else { + ContentUnavailableView { + Label("Unable to open list", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage ?? "This list could not be loaded.") + } actions: { + Button("Retry") { Task { await load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + errorMessage = nil + hasNoAccess = false + isLoading = true + defer { isLoading = false } + if let known = store.userLists.first(where: { $0.id == listId }) + ?? store.watchedLists.first(where: { $0.id == listId }) { + list = known + return + } + do { + list = try await APIClient.shared.list(id: listId) + } catch ListAccessError.noAccess { + hasNoAccess = true + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "You need to be signed in to view this list." + } catch APIError.server(let msg) { + errorMessage = msg + } catch { + errorMessage = "This list could not be loaded." + } + } +} + +#Preview { + ListLinkView(listId: "preview-id") + .environmentObject(AuthState()) + .environmentObject(AppDataStore()) +} diff --git a/InterlinedListTests/APIClientTests/APIClientSingleListTests.swift b/InterlinedListTests/APIClientTests/APIClientSingleListTests.swift new file mode 100644 index 0000000..41de030 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientSingleListTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import InterlinedList + +/// W7. `GET /api/lists/:id` is what makes a bare `/lists/` permalink openable: +/// it is Bearer-ready and authorizes by role, so the URL needs no owner username. +/// These pin the route, the `{ data: … }` envelope, and — the part a permalink +/// depends on — that a 403/404 surfaces as "no access" rather than a generic +/// server error the opener cannot tell apart from a real failure. +final class APIClientSingleListTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + /// Shape of the live route: the whole list row under `data`. + private var listJSON: String { + #""" + {"data":{"id":"l1","title":"Team Roadmap","description":"Q4","userId":"owner-1", + "parentId":null,"isPublic":false,"createdAt":"2026-09-01T00:00:00.000Z", + "updatedAt":"2026-09-02T00:00:00.000Z","source":"local"}} + """# + } + + func test_list_sendsGetToListPath() async throws { + session.stub(json: listJSON) + _ = try await sut.list(id: "l1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1") + } + + func test_list_sendsBearerToken() async throws { + session.stub(json: listJSON) + _ = try await sut.list(id: "l1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_list_percentEncodesId() async throws { + session.stub(json: listJSON) + _ = try await sut.list(id: "a b") + XCTAssertEqual(session.lastRequest?.url?.absoluteString.hasSuffix("/api/lists/a%20b"), true) + } + + func test_list_decodesTitleAsNameAndOwner() async throws { + session.stub(json: listJSON) + let list = try await sut.list(id: "l1") + XCTAssertEqual(list.id, "l1") + XCTAssertEqual(list.name, "Team Roadmap") + XCTAssertEqual(list.ownerId, "owner-1") + XCTAssertFalse(list.isOwned(by: "someone-else")) + } + + /// The backend answers a forbidden list with a body, which the generic mapping + /// would turn into `.forbidden` — the endpoint must normalise it instead. + func test_list_forbiddenWithBody_throwsNoAccess() async { + session.stub(json: #"{"error":"Forbidden","code":"forbidden"}"#, statusCode: 403) + await assertNoAccess() + } + + /// A list the viewer has no access record for comes back 404 with a body, + /// which would otherwise flatten into `.server("List not found")`. + func test_list_notFoundWithBody_throwsNoAccess() async { + session.stub(json: #"{"error":"List not found","code":"not_found"}"#, statusCode: 404) + await assertNoAccess() + } + + func test_list_bodylessForbidden_throwsNoAccess() async { + session.stub(json: "", statusCode: 403) + await assertNoAccess() + } + + /// 401 still means "re-check the session", never "no access" — the view routes + /// it to `handleUnauthorized()` instead of the no-access state. + func test_list_unauthorized_throwsStatus401() async { + session.stub(json: #"{"error":"Unauthorized"}"#, statusCode: 401) + do { + _ = try await sut.list(id: "l1") + XCTFail("expected 401") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func test_list_serverError_isNotReportedAsNoAccess() async { + session.stub(json: #"{"error":"Something went wrong"}"#, statusCode: 500) + do { + _ = try await sut.list(id: "l1") + XCTFail("expected failure") + } catch is ListAccessError { + XCTFail("a 500 must not read as no access") + } catch { + // Any other mapping is fine; the point is it isn't `noAccess`. + } + } + + private func assertNoAccess(file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await sut.list(id: "l1") + XCTFail("expected noAccess", file: file, line: line) + } catch ListAccessError.noAccess { + // expected + } catch { + XCTFail("unexpected error: \(error)", file: file, line: line) + } + } +} diff --git a/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift index 37186fe..2c13379 100644 --- a/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift +++ b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift @@ -33,10 +33,109 @@ final class AppDeepLinkParseTests: XCTestCase { XCTAssertEqual(parse("https://www.interlinedlist.com/message/abc"), .message(id: "abc")) } - func test_parse_httpsCanonicalList_isNotRouted_returnsNil() { - // List permalinks are deliberately not routed: a bare `/lists/` has no - // owner username, which the list-detail endpoint requires (G10 follow-on). - XCTAssertNil(parse("https://interlinedlist.com/lists/xyz")) + // MARK: - W7: every link shape the web can hand a user + // + // One test per row of issue #61's table, in both the custom-scheme and the + // https (bare and www) forms. The rows that already worked are pinned here + // too so the routing table as a whole is regression-locked. + + // /user//status/ — the web's canonical *message* permalink. Parsing + // only the first segment used to open the author's profile instead. + func test_parse_httpsUserStatus_returnsMessage() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob/status/msg-1"), + .message(id: "msg-1")) + } + + func test_parse_httpsWwwUserStatus_returnsMessage() { + XCTAssertEqual(parse("https://www.interlinedlist.com/user/bob/status/msg-1"), + .message(id: "msg-1")) + } + + func test_parse_customSchemeUserStatus_returnsMessage() { + XCTAssertEqual(parse("interlinedlist://user/bob/status/msg-1"), + .message(id: "msg-1")) + } + + // /user//lists/ — public list permalink. + func test_parse_httpsUserPublicList_returnsPublicList() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob/lists/l-1"), + .publicList(owner: "bob", id: "l-1")) + } + + func test_parse_httpsWwwUserPublicList_returnsPublicList() { + XCTAssertEqual(parse("https://www.interlinedlist.com/user/bob/lists/l-1"), + .publicList(owner: "bob", id: "l-1")) + } + + func test_parse_customSchemeUserPublicList_returnsPublicList() { + XCTAssertEqual(parse("interlinedlist://user/bob/lists/l-1"), + .publicList(owner: "bob", id: "l-1")) + } + + // /user//documents/ — public document permalink. + func test_parse_httpsUserPublicDocument_returnsPublicDocument() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob/documents/d-1"), + .publicDocument(owner: "bob", id: "d-1")) + } + + func test_parse_httpsWwwUserPublicDocument_returnsPublicDocument() { + XCTAssertEqual(parse("https://www.interlinedlist.com/user/bob/documents/d-1"), + .publicDocument(owner: "bob", id: "d-1")) + } + + func test_parse_customSchemeUserPublicDocument_returnsPublicDocument() { + XCTAssertEqual(parse("interlinedlist://user/bob/documents/d-1"), + .publicDocument(owner: "bob", id: "d-1")) + } + + // A profile link is still a profile link, and so is anything under a profile + // that is not one of the three known sub-shapes. + func test_parse_httpsUserUnknownSubsection_fallsBackToUserProfile() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob/followers/x"), + .userProfile(username: "bob")) + } + + func test_parse_httpsUserStatusWithoutId_fallsBackToUserProfile() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob/status"), + .userProfile(username: "bob")) + } + + // /lists/ — the permalink iOS itself generates (`ILWebURL.list(_:)`). + // `GET /api/lists/:id` authorizes by role, so no owner username is needed. + func test_parse_httpsCanonicalList_returnsList() { + XCTAssertEqual(parse("https://interlinedlist.com/lists/xyz"), .list(id: "xyz")) + } + + func test_parse_httpsWwwList_returnsList() { + XCTAssertEqual(parse("https://www.interlinedlist.com/lists/xyz"), .list(id: "xyz")) + } + + func test_parse_customSchemeList_returnsList() { + XCTAssertEqual(parse("interlinedlist://lists/xyz"), .list(id: "xyz")) + } + + func test_parse_ilWebURLListRoundTrips_toListDeepLink() { + // The exact URL the share sheet copies must open the list it names. + guard let url = ILWebURL.list("xyz") else { return XCTFail("no URL") } + XCTAssertEqual(AppDeepLink.parse(url), .list(id: "xyz")) + } + + func test_parse_listsWithNoId_returnsNil() { + XCTAssertNil(parse("https://interlinedlist.com/lists")) + } + + // The rows that already worked. + func test_parse_httpsCanonicalMessage_returnsMessage() { + XCTAssertEqual(parse("https://interlinedlist.com/message/abc"), .message(id: "abc")) + } + + // Foreign hosts stay unroutable even on the newly-accepted shapes. + func test_parse_foreignHostUserStatus_returnsNil() { + XCTAssertNil(parse("https://example.com/user/bob/status/msg-1")) + } + + func test_parse_foreignHostList_returnsNil() { + XCTAssertNil(parse("https://example.com/lists/xyz")) } // MARK: Documents (G10) @@ -83,10 +182,10 @@ final class AppDeepLinkParseTests: XCTestCase { XCTAssertNil(parse("https://interlinedlist.com/lists/shared")) } - func test_parse_bareListPermalink_isNotRouted_returnsNil() { - // A bare `/lists/` carries no owner username the list-detail endpoint - // needs, so it stays deferred; only the self-contained shared-token form routes. - XCTAssertNil(parse("https://interlinedlist.com/lists/list-1")) + func test_parse_bareListPermalink_returnsList() { + // Routed as of W7: `GET /api/lists/:id` authorizes by role, so the owner + // username the old comment claimed was required is not needed at all. + XCTAssertEqual(parse("https://interlinedlist.com/lists/list-1"), .list(id: "list-1")) } // MARK: Rejections diff --git a/the-gaps.md b/the-gaps.md index 299a629..88b733a 100644 --- a/the-gaps.md +++ b/the-gaps.md @@ -154,7 +154,7 @@ Live implementation status — updated as work lands on `dev` (uncommitted unles | **17 — GitHub OAuth + Universal Links entitlement (A1/A2 iOS tail)** | `feat/github-oauth-universal-links` merged: GitHub `supportsNativeAuth → true` (in-app linking via the backend's mobile OAuth branch) + `com.apple.developer.associated-domains = applinks:interlinedlist.com` in `InterlinedList.entitlements` | ✅ **Done** 2026-08 — code side complete. **Residual is non-code:** enable Associated Domains on the App ID in the Apple portal & regen the provisioning profile (tracked in `App-Store-Deployment-Checklist.md`) | | **18 — sharing parity + GitHub-list fixes** | Unified document + list sharing screen (web parity) · inbound shared-list viewer + web-accept for edit links · GitHub issue list refresh after close/reopen · multi-image compose | ✅ **Done** 2026-09-02/05 (PRs #30–#32, restored to `main` in #35) | | **19 — offline-sync `relativePath` workaround** | Send `relativePath` on every sync create/update/conflict-copy op so the backend stops silently dropping them (see ask **A7**) | ✅ **Done** 2026-09-02 (`a7d0976`, PR #34) | -| — | **G10 list follow-on** — bare `/lists/:id` permalink inbound *(share-token inbound already ships: `/lists/shared/:token` → read-only `SharedListView` via `resolveSharedList()`/`sharedListData()`)* | ⛔ **backend-limited** — the permalink carries no owner username | +| — | **G10 list follow-on** — bare `/lists/:id` permalink inbound *(share-token inbound already ships: `/lists/shared/:token` → read-only `SharedListView` via `resolveSharedList()`/`sharedListData()`)* | ✅ **Done** (W7, issue #61) — `.list(id:)` → `ListLinkView` → `ListDetailView`. The earlier "backend-limited, carries no owner username" claim was wrong: `GET /api/lists/:id` is Bearer-ready and authorizes by role (owner/manager/collaborator/watcher), so no owner is needed in the URL | | — | **G11 document presence** — `/api/documents/:id/presence` heartbeat + poll | ⏳ **remaining, optional** — the only unbuilt parity item | **Whole-tree gate (2026-07-31, after the fix pass):** full unit suite **694 tests, @@ -332,7 +332,7 @@ directly from source: | **GitHub-backed lists / issues** | ❌ | **G4** — now Bearer-ready | | **LinkedIn org/target picker** | ◑ | **G5** — `linkedInTargets` posts; can't fetch targets yet | | **Offline document sync** | ❌ | **G9** | -| **Content deep / universal links** | ◑ | **G10** — profile/message/**document**/**doc-share-token**/**list-share-token** routed; bare list permalinks backend-limited (no owner in URL); Universal Links pending A2 iOS merge | +| **Content deep / universal links** | ◑ | **G10** — profile/message/**document**/**doc-share-token**/**list-share-token** routed; **W7 (#61)** adds the web's canonical shapes: `/user/:u/status/:id` → message, `/user/:u/lists|documents/:id` → public list/document, bare `/lists/:id` → list detail (authorized by role, no owner needed). AASA already wildcards `/user/*`, `/message/*`, `/lists/*`, `/documents/*`; Universal Links pending A2 iOS merge | | **Live doc presence (cursors)** | ❌ | **G11** | | **Active-sessions management** | ❌ | **G12** | | Multi-account switching | ⛔ | **X1** — session-only backend |