diff --git a/App/Features/Lists/GitHubIssuesView.swift b/App/Features/Lists/GitHubIssuesView.swift index f7bf0f0..bc1aac7 100644 --- a/App/Features/Lists/GitHubIssuesView.swift +++ b/App/Features/Lists/GitHubIssuesView.swift @@ -334,19 +334,16 @@ private struct GitHubIssueDetailView: View { .padding() } + // NOTE: there is deliberately no Close / Reopen control here. The live + // `PATCH /api/github/issues/{owner}/{repo}/{number}` route only sets labels + // and assignees — a `state` change comes back + // `400 "labels or assignees required"` (verified 2026-09-06, + // work-consolidation.md §1c · V7). The button used to sit in this bar and + // could never have worked against production, so it was removed rather than + // left to fail; "Open on GitHub" in the header is the working path until the + // backend grows a state route. private var editingBar: some View { HStack(spacing: 8) { - Button { - Task { await viewModel.toggleState(issue) } - } label: { - if issue.state == .closed { - Label("Reopen", systemImage: "arrow.counterclockwise.circle") - } else { - Label("Close", systemImage: "checkmark.circle") - } - } - .disabled(viewModel.isUpdating) - Menu { if viewModel.labelCatalog.isEmpty { Text(viewModel.isLoadingCatalog ? "Loading…" : "No labels") diff --git a/App/Features/Lists/GitHubIssuesViewModel.swift b/App/Features/Lists/GitHubIssuesViewModel.swift index deb1792..8263af8 100644 --- a/App/Features/Lists/GitHubIssuesViewModel.swift +++ b/App/Features/Lists/GitHubIssuesViewModel.swift @@ -168,11 +168,11 @@ final class GitHubIssuesViewModel { } } - /// Closes an open issue or reopens a closed one. - func toggleState(_ issue: GitHubIssue) async { - let newState: GitHubIssueState = issue.state == .closed ? .open : .closed - await applyUpdate(to: issue.number, GitHubIssueUpdate(state: newState)) - } + // No `toggleState` here: closing / reopening an issue has no live route. + // `PATCH /api/github/issues/{owner}/{repo}/{number}` rejects a `state`-only + // body with 400 "labels or assignees required" (work-consolidation.md + // §1c · V7), and `GitHubService.updateIssue` now refuses such an update up + // front with `GitHubServiceError.unsupportedIssueEdit`. /// Replaces the label set on `issue`. func setLabels(_ labels: [String], on issue: GitHubIssue) async { @@ -216,6 +216,11 @@ final class GitHubIssuesViewModel { switch error { case .notLinked: linkState = .notLinked + case .unsupportedIssueEdit: + // Not a linking problem — the live API simply has no route for this + // edit, so surface the message instead of showing a "Link GitHub" + // CTA the user has already satisfied. + self.error = error } } diff --git a/App/Features/Timeline/CreateIssueFromMessageViewModel.swift b/App/Features/Timeline/CreateIssueFromMessageViewModel.swift index 4778253..cc32afc 100644 --- a/App/Features/Timeline/CreateIssueFromMessageViewModel.swift +++ b/App/Features/Timeline/CreateIssueFromMessageViewModel.swift @@ -149,6 +149,11 @@ final class CreateIssueFromMessageViewModel { switch error { case .notLinked: linkState = .notLinked + case .unsupportedIssueEdit: + // Not a linking problem — the live API simply has no route for this + // edit, so surface the message instead of showing a "Link GitHub" + // CTA the user has already satisfied. + self.error = error } } } diff --git a/AppTests/GitHubIssuesViewModelTests.swift b/AppTests/GitHubIssuesViewModelTests.swift index d5cd5c0..6ff12c8 100644 --- a/AppTests/GitHubIssuesViewModelTests.swift +++ b/AppTests/GitHubIssuesViewModelTests.swift @@ -182,29 +182,22 @@ final class GitHubIssuesViewModelTests: XCTestCase { XCTAssertEqual(vm.assignableUsers.map(\.login), ["octocat"]) } - func test_givenOpenIssue_whenTogglingState_thenClosesAndSwapsReturnedCopy() async { + // The former `toggleState` close/reopen tests are gone with the method: the + // live API has no route that changes an issue's state + // (work-consolidation.md §1c · V7). `GitHubServiceTests` covers the refusal + // at the service boundary instead. + + func test_givenLabelUpdate_whenApplied_thenSwapsReturnedCopyIntoList() async { let stub = StubGitHubService() stub.issuesResult = .success([makeIssue(7, state: .open)]) - stub.updateResult = .success(makeIssue(7, title: "T", state: .closed)) + stub.updateResult = .success(makeIssue(7, title: "Relabelled", state: .open)) let vm = GitHubIssuesViewModel(github: stub, repo: "o/r") await vm.load() - await vm.toggleState(makeIssue(7, state: .open)) + await vm.setLabels(["bug"], on: makeIssue(7, state: .open)) - XCTAssertEqual(vm.issues.first?.state, .closed) + XCTAssertEqual(vm.issues.first?.title, "Relabelled") XCTAssertEqual(stub.updates.first?.number, 7) - XCTAssertEqual(stub.updates.first?.update.state, .closed) - } - - func test_givenClosedIssue_whenTogglingState_thenReopens() async { - let stub = StubGitHubService() - stub.issuesResult = .success([makeIssue(7, state: .closed)]) - let vm = GitHubIssuesViewModel(github: stub, repo: "o/r") - await vm.load() - - await vm.toggleState(makeIssue(7, state: .closed)) - - XCTAssertEqual(stub.updates.first?.update.state, .open) } func test_givenLabels_whenSetting_thenSendsLabelUpdate() async { @@ -236,7 +229,7 @@ final class GitHubIssuesViewModelTests: XCTestCase { let vm = GitHubIssuesViewModel(github: stub, repo: "o/r") await vm.load() - await vm.toggleState(makeIssue(7)) + await vm.setLabels(["bug"], on: makeIssue(7)) XCTAssertEqual(vm.linkState, .notLinked) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/GitHubIssue.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/GitHubIssue.swift index 1d61ec0..5be1971 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/GitHubIssue.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/GitHubIssue.swift @@ -268,4 +268,28 @@ public struct GitHubIssueUpdate: Sendable, Equatable { /// "Link GitHub" CTA that deep-links the existing native OAuth flow. public enum GitHubServiceError: Error, Equatable, Sendable { case notLinked(message: String) + + /// The requested issue edit is not something the live API can perform. + /// + /// `PATCH /api/github/issues/{owner}/{repo}/{number}` is a **labels and + /// assignees** route: it answers `400 "labels or assignees required"` when + /// the body carries only `state`, `title` or `body` (verified 2026-09-06 — + /// work-consolidation.md §1c · V7). Closing / reopening an issue and + /// editing its title or body have no live route, so `GitHubService` raises + /// this instead of firing a request that is guaranteed to 400. + case unsupportedIssueEdit +} + +extension GitHubServiceError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + + public var description: String { + switch self { + case .notLinked(let message): + return message + case .unsupportedIssueEdit: + return "InterlinedList can only change an issue's labels and assignees. " + + "Closing, reopening, or renaming an issue has to be done on GitHub." + } + } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift index 7945f53..307c22a 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift @@ -224,7 +224,8 @@ public final class DocumentsService: DocumentsServicing { public func document(id: String) async throws -> Document { do { - let dto = try await api.send(Documents.get(id: id)) + // The live read answers `{ document }`; unwrap it. + let dto = try await api.send(Documents.get(id: id)).document return Document(from: dto) } catch let error as APIError { if case .notFound = error { @@ -247,7 +248,8 @@ public final class DocumentsService: DocumentsServicing { relativePath: nil, isPublic: isPublic ) - let dto = try await api.send(Documents.create(req)) + // The live create answers `{ message, document }`; unwrap it. + let dto = try await api.send(Documents.create(req)).document return Document(from: dto) } @@ -265,7 +267,8 @@ public final class DocumentsService: DocumentsServicing { isPublic: isPublic ) do { - let dto = try await api.send(Documents.update(id: id, req)) + // The live update answers `{ message, document }`; unwrap it. + let dto = try await api.send(Documents.update(id: id, req)).document return Document(from: dto) } catch let error as APIError { if case .notFound = error { @@ -355,7 +358,8 @@ public final class DocumentsService: DocumentsServicing { public func folder(id: String) async throws -> FolderNode { do { - let dto = try await api.send(Documents.folder(id: id)) + // The live read answers `{ folder }`; unwrap it. + let dto = try await api.send(Documents.folder(id: id)).folder return FolderNode(from: dto) } catch let error as APIError { if case .notFound = error { @@ -367,14 +371,16 @@ public final class DocumentsService: DocumentsServicing { public func createFolder(name: String, parentId: String?) async throws -> FolderNode { let req = CreateDocumentFolderRequest(name: name, parentId: parentId) - let dto = try await api.send(Documents.createFolder(req)) + // The live create answers `{ message, folder }`; unwrap it. + let dto = try await api.send(Documents.createFolder(req)).folder return FolderNode(from: dto) } public func renameFolder(id: String, to name: String) async throws -> FolderNode { let req = UpdateDocumentFolderRequest(name: name, parentId: nil) do { - let dto = try await api.send(Documents.updateFolder(id: id, req)) + // The live update answers `{ message, folder }`; unwrap it. + let dto = try await api.send(Documents.updateFolder(id: id, req)).folder return FolderNode(from: dto) } catch let error as APIError { if case .notFound = error { diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/GitHubService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/GitHubService.swift index 7578c68..baa7917 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/GitHubService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/GitHubService.swift @@ -86,7 +86,14 @@ public final class GitHubService: GitHubServicing { } public func updateIssue(repo: String, number: Int, _ update: GitHubIssueUpdate) async throws -> GitHubIssue { - try await mappingNotLinked { + // The live route sets labels/assignees only — a state/title/body-only + // edit is rejected with 400 "labels or assignees required", so refuse it + // here rather than spending a round trip to learn that + // (work-consolidation.md §1c · V7). + guard update.labels != nil || update.assignees != nil else { + throw GitHubServiceError.unsupportedIssueEdit + } + return try await mappingNotLinked { let request = UpdateGitHubIssueRequest( title: update.title, body: update.body, diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift index 2127fc7..351ae37 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -406,7 +406,8 @@ public final class ListsService: ListsServicing { public func row(listId: String, rowId: String) async throws -> ListRow { try requireListManagement() - let dto = try await api.send(Lists.row(listId: listId, rowId: rowId)) + // The live read answers `{ data }`; unwrap it. + let dto = try await api.send(Lists.row(listId: listId, rowId: rowId)).data return ListRow(from: dto) } @@ -414,7 +415,8 @@ public final class ListsService: ListsServicing { try requireListManagement() let wire = data.mapValues(ListJSONValue.init(from:)) let request = CreateListRowRequest(rowData: wire) - let dto = try await api.send(Lists.createRow(listId: listId, request)) + // The live create answers `{ message, data }`; unwrap it. + let dto = try await api.send(Lists.createRow(listId: listId, request)).data return ListRow(from: dto) } @@ -426,7 +428,8 @@ public final class ListsService: ListsServicing { try requireListManagement() let wire = data.mapValues(ListJSONValue.init(from:)) let request = UpdateListRowRequest(rowData: wire) - let dto = try await api.send(Lists.updateRow(listId: listId, rowId: rowId, request)) + // The live update answers `{ message, data }`; unwrap it. + let dto = try await api.send(Lists.updateRow(listId: listId, rowId: rowId, request)).data return ListRow(from: dto) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift index 2ee8a3e..99c1928 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -30,6 +30,18 @@ public enum MessagesError: Error, Sendable, Equatable { /// the UI can tell the user how much to trim (PLAN.md §8 — "clear errors /// when impossible"). case mediaTooLarge(byteCount: Int, limit: Int) + + /// The requested write has **no route on the live API**, so the client + /// refuses it up front rather than firing a call that cannot succeed. + /// + /// Raised by `update(messageId:body:tags:visibility:)`: editing a published + /// message is not something the server offers. `PATCH /api/messages/[id]` + /// exists but only reschedules a future scheduled post — it answers + /// `400 "No valid updates provided"` for a content body and silently drops + /// `content` when it accompanies `scheduledAt` (verified 2026-09-06, + /// work-consolidation.md §1c · V1). Throwing here keeps the failure honest + /// and local instead of letting the UI believe an edit was saved. + case editingNotSupported } extension MessagesError: LocalizedError, CustomStringConvertible { @@ -48,6 +60,9 @@ extension MessagesError: LocalizedError, CustomStringConvertible { } case .mediaTooLarge(let byteCount, let limit): return "This file is \(byteCount) bytes, over the \(limit)-byte limit." + case .editingNotSupported: + return "InterlinedList does not support editing a message after it is posted. " + + "You can reschedule a post that has not gone out yet, or delete this one and post again." } } } @@ -134,8 +149,17 @@ public protocol MessagesServicing: Sendable { visibility: Visibility ) async throws -> Message - /// Edits an existing message in place. The full body/tags/visibility are - /// resent — this is a PUT, not a PATCH, matching the kit builder. + /// Editing a posted message. + /// + /// - Important: **Always throws `MessagesError.editingNotSupported`.** The + /// live API has no route that edits a message's content: the only write + /// on `/api/messages/[id]` is the `PATCH` reschedule, which rejects a + /// content body outright and silently discards `content` sent next to + /// `scheduledAt` (verified 2026-09-06 — work-consolidation.md §1c · V1). + /// The method is kept so the capability gap is explicit at the call site + /// and typed for the UI, rather than being a call that quietly no-ops. + /// Use `reschedule(messageId:newDate:)` for the write the server *does* + /// support. func update( messageId: String, body: String, @@ -498,15 +522,9 @@ public final class MessagesService: MessagesServicing { tags: [String], visibility: Visibility ) async throws -> Message { - let request = CreateMessageRequest( - content: body, - publiclyVisible: visibility.isPubliclyVisible, - tags: tags.isEmpty ? nil : tags - ) - let dto = try await api.send(Messages.update(id: messageId, request)).message - let message = Message(from: dto) - await store?.upsert([message]) - return message + // No live route edits a posted message — fail fast and honestly rather + // than sending a request the server will reject or silently ignore. + throw MessagesError.editingNotSupported } public func delete(messageId: String) async throws { @@ -679,15 +697,15 @@ public final class MessagesService: MessagesServicing { } public func reschedule(messageId: String, newDate: Date) async throws -> Message { - let existing = try await message(id: messageId) - let request = CreateMessageRequest( - content: existing.text, - publiclyVisible: existing.visibility.isPubliclyVisible, - tags: existing.tags.isEmpty ? nil : existing.tags, - scheduledAt: newDate - ) - let dto = try await api.send(Messages.update(id: messageId, request)).message + // `scheduledAt` is the only key the live PATCH honours, so send just + // that. The previous implementation round-tripped the whole message + // through `CreateMessageRequest`, which cost an extra GET and shipped + // content/tags/visibility fields the server discards. + let request = RescheduleMessageRequest(scheduledAt: newDate) + // The reply is a bare `MessageDTO`, not the create envelope. + let dto = try await api.send(Messages.reschedule(id: messageId, request)) let updated = Message(from: dto) + await store?.upsert([updated]) return updated } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift index 3c11ba3..51da730 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift @@ -128,12 +128,14 @@ public final class OrgService: OrgServicing { isPublic: Bool ) async throws -> Organization { let body = CreateOrganizationRequest(name: name, description: description, isPublic: isPublic) - let dto = try await api.send(Organizations.create(body)) + // The live create answers `{ message, organization }`; unwrap it. + let dto = try await api.send(Organizations.create(body)).organization return Organization(from: dto) } public func organization(id: String) async throws -> Organization { - let dto = try await api.send(Organizations.get(id: id)) + // The live read answers `{ organization }`; unwrap it. + let dto = try await api.send(Organizations.get(id: id)).organization return Organization(from: dto) } @@ -144,7 +146,8 @@ public final class OrgService: OrgServicing { isPublic: Bool? ) async throws -> Organization { let body = UpdateOrganizationRequest(name: name, description: description, isPublic: isPublic) - let dto = try await api.send(Organizations.update(id: id, body)) + // The live update answers `{ message, organization }`; unwrap it. + let dto = try await api.send(Organizations.update(id: id, body)).organization return Organization(from: dto) } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift index 02a1291..e1bc90e 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift @@ -73,7 +73,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenDocumentId_whenLoadingDetail_thenMapsAllFields() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d-42", title: "Notes", content: "# H")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-42", title: "Notes", content: "# H")) let service = DocumentsService(api: api) // When @@ -118,7 +118,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenDocumentDetailWithEmptyContent_whenLoading_thenBodyIsEmpty() async throws { // Given — boundary: server returns null content. let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d", content: nil)) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d", content: nil)) let service = DocumentsService(api: api) // When @@ -133,7 +133,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenTitleAndBody_whenCreating_thenPostsToDocumentsAndReturnsMapped() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d-new", title: "New", content: "Body")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-new", title: "New", content: "Body")) let service = DocumentsService(api: api) // When @@ -164,7 +164,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenEmptyBody_whenCreating_thenStillPostsAndReturnsServerResponse() async throws { // Given — boundary: API permits empty body. let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d-e", content: "")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-e", content: "")) let service = DocumentsService(api: api) // When @@ -179,7 +179,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenPartialEdit_whenUpdating_thenPatchesDocumentAndReturnsMapped() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d", title: "Renamed")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d", title: "Renamed")) let service = DocumentsService(api: api) // When @@ -225,7 +225,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenAllFieldsNil_whenUpdating_thenStillPatches() async throws { // Given — boundary: every field nil. Server may reject; service does not. let api = StubAPIClient() - await api.enqueue(json: Fixtures.documentObject(id: "d")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d")) let service = DocumentsService(api: api) // When / Then — no throw. @@ -413,7 +413,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenFolderId_whenLoadingFolder_thenMapsFolder() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.folderObject(id: "f1", name: "Inbox")) + await api.enqueue(json: Fixtures.folderEnvelope(id: "f1", name: "Inbox")) let service = DocumentsService(api: api) // When @@ -442,7 +442,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenName_whenCreatingFolder_thenPostsAndMaps() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.folderObject(id: "f-new", name: "New")) + await api.enqueue(json: Fixtures.folderEnvelope(id: "f-new", name: "New")) let service = DocumentsService(api: api) // When @@ -470,10 +470,10 @@ final class DocumentsServiceTests: XCTestCase { } } - func test_givenFolderId_whenRenamingFolder_thenPatchesAndMaps() async throws { + func test_givenFolderId_whenRenamingFolder_thenPutsAndMaps() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.folderObject(id: "f1", name: "Renamed")) + await api.enqueue(json: Fixtures.folderEnvelope(id: "f1", name: "Renamed")) let service = DocumentsService(api: api) // When @@ -482,7 +482,8 @@ final class DocumentsServiceTests: XCTestCase { // Then XCTAssertEqual(folder.name, "Renamed") let recorded = await api.recorded - XCTAssertEqual(recorded.first?.method, "PATCH") + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V5). + XCTAssertEqual(recorded.first?.method, "PUT") XCTAssertEqual(recorded.first?.path, "/api/documents/folders/f1") } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GitHubServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GitHubServiceTests.swift index 1e58559..a3339ca 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GitHubServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GitHubServiceTests.swift @@ -99,17 +99,70 @@ final class GitHubServiceTests: XCTestCase { XCTAssertEqual(recorded.first?.query["repo"], "o/r") } - func test_givenStateUpdate_whenUpdating_thenPatchesIssue() async throws { + // The live `PATCH /api/github/issues/{owner}/{repo}/{number}` sets labels + // and assignees only. A state/title/body-only body comes back + // `400 "labels or assignees required"` (verified 2026-09-06 — + // work-consolidation.md §1c · V7), so the service refuses it locally. + func test_givenLabelUpdate_whenUpdating_thenPatchesFlatIssuePath() async throws { let api = StubAPIClient() - await api.enqueue(json: #"{"number":21,"title":"New bug","state":"closed"}"#) + await api.enqueue(json: #"{"number":21,"title":"New bug","state":"open"}"#) let service = GitHubService(api: api) - let issue = try await service.updateIssue(repo: "o/r", number: 21, GitHubIssueUpdate(state: .closed)) + let issue = try await service.updateIssue( + repo: "o/r", + number: 21, + GitHubIssueUpdate(labels: ["bug"]) + ) - XCTAssertEqual(issue.state, .closed) + XCTAssertEqual(issue.number, 21) let recorded = await api.recorded XCTAssertEqual(recorded.first?.method, "PATCH") - XCTAssertEqual(recorded.first?.path, "/api/github/repos/o/r/issues/21") + XCTAssertEqual(recorded.first?.path, "/api/github/issues/o/r/21") + } + + func test_givenStateOnlyUpdate_whenUpdating_thenThrowsWithoutCallingAPI() async throws { + let api = StubAPIClient() + let service = GitHubService(api: api) + + do { + _ = try await service.updateIssue(repo: "o/r", number: 21, GitHubIssueUpdate(state: .closed)) + XCTFail("Expected unsupportedIssueEdit") + } catch let error as GitHubServiceError { + XCTAssertEqual(error, .unsupportedIssueEdit) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // Boundary: a title/body edit is refused on the same grounds — the route + // ignores both unless labels/assignees accompany them. + func test_givenTitleOnlyUpdate_whenUpdating_thenThrowsUnsupported() async throws { + let api = StubAPIClient() + let service = GitHubService(api: api) + + do { + _ = try await service.updateIssue(repo: "o/r", number: 21, GitHubIssueUpdate(title: "Renamed")) + XCTFail("Expected unsupportedIssueEdit") + } catch let error as GitHubServiceError { + XCTAssertEqual(error, .unsupportedIssueEdit) + } + } + + // Boundary: assignees alone is enough to satisfy the route's requirement. + func test_givenAssigneesOnlyUpdate_whenUpdating_thenIsAccepted() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"number":21,"title":"New bug","state":"open"}"#) + let service = GitHubService(api: api) + + _ = try await service.updateIssue( + repo: "o/r", + number: 21, + GitHubIssueUpdate(assignees: ["octocat"]) + ) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) } func test_givenComment_whenAdding_thenPostsAndReturnsComment() async throws { @@ -122,7 +175,8 @@ final class GitHubServiceTests: XCTestCase { XCTAssertEqual(comment.body, "thanks") XCTAssertEqual(comment.author?.login, "octocat") let recorded = await api.recorded - XCTAssertEqual(recorded.first?.path, "/api/github/repos/o/r/issues/21/comments") + // Flat comment route, verified live 2026-09-06 (§1c · V7). + XCTAssertEqual(recorded.first?.path, "/api/github/issues/o/r/21/comments") } // MARK: - Labels / assignees / next number diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift index c39037c..56a45b1 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift @@ -460,67 +460,119 @@ final class MessagesServiceTests: XCTestCase { } } - // MARK: - M2 write surface: update (edit) + // MARK: - M2 write surface: update (edit) — unsupported upstream - func test_givenEdits_whenUpdating_thenPutsToMessageIdAndCachesResult() async throws { + // The live API has no route that edits a posted message. `PUT + // /api/messages/[id]` is 405 and the `PATCH` that replaced it only moves a + // scheduled post's send time, rejecting a content body with + // `400 "No valid updates provided"` and silently dropping `content` sent + // beside `scheduledAt` (verified 2026-09-06 — work-consolidation.md §1c · + // V1). `update` therefore refuses locally instead of issuing a doomed call. + + func test_givenEdits_whenUpdating_thenThrowsEditingNotSupportedWithoutCallingAPI() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "edited")) let store = InMemoryMessageStore() let service = MessagesService(api: api, store: store) - // When - let updated = try await service.update( - messageId: "m-42", - body: "edited", - tags: ["swift"], - visibility: .public - ) + // When / Then + do { + _ = try await service.update( + messageId: "m-42", + body: "edited", + tags: ["swift"], + visibility: .public + ) + XCTFail("Expected editingNotSupported") + } catch let error as MessagesError { + XCTAssertEqual(error, .editingNotSupported) + } - // Then - XCTAssertEqual(updated.text, "edited") + // And — no request was made, and nothing was written to the cache. let recorded = await api.recorded - XCTAssertEqual(recorded.first?.method, "PUT") - XCTAssertEqual(recorded.first?.path, "/api/messages/m-42") + XCTAssertTrue(recorded.isEmpty) let cached = await store.cachedMessage(id: "m-42") - XCTAssertEqual(cached?.text, "edited") + XCTAssertNil(cached) } - func test_givenEmptyBody_whenUpdating_thenStillIssuesPut() async throws { - // Given — boundary: empty body. Forwarded as-is; server validates. + func test_givenEmptyBody_whenUpdating_thenStillThrowsEditingNotSupported() async throws { + // Boundary: an empty edit is refused on the same grounds, not passed + // through for the server to validate. let api = StubAPIClient() - await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "")) let service = MessagesService(api: api) + do { + _ = try await service.update(messageId: "m-42", body: "", tags: [], visibility: .public) + XCTFail("Expected editingNotSupported") + } catch let error as MessagesError { + XCTAssertEqual(error, .editingNotSupported) + } + } + + func test_givenEditingNotSupported_whenDescribed_thenExplainsTheAlternative() { + // The message is user-facing, so it must name what the user *can* do. + let description = MessagesError.editingNotSupported.description + XCTAssertTrue(description.contains("reschedule")) + XCTAssertTrue(description.contains("delete")) + } + + // MARK: - reschedule (the write the live API does support) + + func test_givenNewDate_whenRescheduling_thenPatchesWithScheduledAtOnly() async throws { + // Given — the live reply is a bare MessageDTO, not the create envelope. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "scheduled")) + let store = InMemoryMessageStore() + let service = MessagesService(api: api, store: store) + let newDate = Date(timeIntervalSince1970: 1_800_000_000) + // When - let updated = try await service.update( + let updated = try await service.reschedule(messageId: "m-42", newDate: newDate) + + // Then + XCTAssertEqual(updated.id, "m-42") + let recorded = await api.recorded + // Exactly one call: the old implementation did a GET first to re-send + // the whole message, which the server discards anyway. + XCTAssertEqual(recorded.count, 1) + XCTAssertEqual(recorded.first?.method, "PATCH") + XCTAssertEqual(recorded.first?.path, "/api/messages/m-42") + } + + func test_givenReschedule_whenSucceeding_thenWritesThroughToTheCache() async throws { + // Boundary: the rescheduled copy must land in the store so the + // Scheduled pane repaints from cache without a refetch. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "scheduled")) + let store = InMemoryMessageStore() + let service = MessagesService(api: api, store: store) + + _ = try await service.reschedule( messageId: "m-42", - body: "", - tags: [], - visibility: .public + newDate: Date(timeIntervalSince1970: 1_800_000_000) ) - // Then - XCTAssertEqual(updated.text, "") + let cached = await store.cachedMessage(id: "m-42") + XCTAssertEqual(cached?.id, "m-42") } - func test_givenUpdateAPIFailure_whenUpdating_thenThrows() async throws { - // Given + func test_givenPublishedMessage_whenRescheduling_thenPropagatesServerRefusal() async throws { + // Upstream failure: the live route rejects an already-published post + // with 400 "Can only edit scheduled posts that are in the future". let api = StubAPIClient() - await api.enqueue(failure: .notFound(serverMessage: "gone")) + await api.enqueue(failure: .badRequest( + serverMessage: "Can only edit scheduled posts that are in the future" + )) let service = MessagesService(api: api) - // When / Then do { - _ = try await service.update( - messageId: "missing", - body: "x", - tags: [], - visibility: .public - ) + _ = try await service.reschedule(messageId: "m-42", newDate: .distantFuture) XCTFail("Expected an APIError") } catch let error as APIError { - XCTAssertEqual(error, .notFound(serverMessage: "gone")) + XCTAssertEqual( + error, + .badRequest(serverMessage: "Can only edit scheduled posts that are in the future") + ) } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift index b1bdbed..a942b4a 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift @@ -85,7 +85,7 @@ final class OrgServiceTests: XCTestCase { func test_givenValidFields_whenCreating_thenPostsBodyAndMapsOrg() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-new", name: "Acme", isPublic: false)) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-new", name: "Acme", isPublic: false)) let service = OrgService(api: api) // When @@ -103,7 +103,7 @@ final class OrgServiceTests: XCTestCase { func test_givenMalformedCreateResponse_whenCreating_thenThrowsDecoding() async throws { // Given — invalid input: response missing required `name`. let api = StubAPIClient() - await api.enqueue(json: #"{"id":"o-new"}"#) + await api.enqueue(json: #"{"organization":{"id":"o-new"}}"#) let service = OrgService(api: api) // When / Then @@ -133,7 +133,7 @@ final class OrgServiceTests: XCTestCase { func test_givenEmptyDescription_whenCreating_thenStillSucceeds() async throws { // Given — boundary: empty description string is accepted by the API. let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-2", description: nil)) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-2", description: nil)) let service = OrgService(api: api) // When @@ -149,7 +149,7 @@ final class OrgServiceTests: XCTestCase { func test_givenExistingId_whenGetting_thenMapsOrg() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-7", name: "Globex")) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7", name: "Globex")) let service = OrgService(api: api) // When @@ -180,7 +180,7 @@ final class OrgServiceTests: XCTestCase { func test_givenMalformedGetResponse_whenGetting_thenThrowsDecoding() async throws { // Given — invalid input. let api = StubAPIClient() - await api.enqueue(json: #"{"oops":true}"#) + await api.enqueue(json: #"{"organization":{"oops":true}}"#) let service = OrgService(api: api) // When / Then @@ -195,7 +195,7 @@ final class OrgServiceTests: XCTestCase { func test_givenOrgWithoutTimestamps_whenGetting_thenMapsNilDates() async throws { // Given — boundary: server omits createdAt / updatedAt. let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-8", includeTimestamps: false)) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-8", includeTimestamps: false)) let service = OrgService(api: api) // When @@ -206,12 +206,12 @@ final class OrgServiceTests: XCTestCase { XCTAssertNil(org.updatedAt) } - // MARK: - update (patch) + // MARK: - update (PUT) - func test_givenPartialPatch_whenUpdating_thenPatchesAndMaps() async throws { + func test_givenPartialPatch_whenUpdating_thenPutsAndMaps() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-7", name: "Renamed")) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7", name: "Renamed")) let service = OrgService(api: api) // When — only `name` changes. @@ -220,7 +220,8 @@ final class OrgServiceTests: XCTestCase { // Then XCTAssertEqual(org.name, "Renamed") let recorded = await api.recorded - XCTAssertEqual(recorded.first?.method, "PATCH") + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V4). + XCTAssertEqual(recorded.first?.method, "PUT") XCTAssertEqual(recorded.first?.path, "/api/organizations/o-7") } @@ -257,7 +258,7 @@ final class OrgServiceTests: XCTestCase { func test_givenAllFieldsNil_whenUpdating_thenStillRoundTrips() async throws { // Given — boundary: a no-op patch (all fields nil). let api = StubAPIClient() - await api.enqueue(json: Fixtures.organizationObject(id: "o-7")) + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7")) let service = OrgService(api: api) // When diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift index bd88b9e..d83175e 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift @@ -669,7 +669,7 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenRowId_whenLoadingRow_thenMapsCells() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.listRowObject(id: "row-7")) + await api.enqueue(json: Fixtures.listRowEnvelope(id: "row-7")) let service = ListsService(api: api) // When @@ -746,7 +746,7 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenRowData_whenCreatingRow_thenPostsAndMapsResponse() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.listRowObject(id: "row-new")) + await api.enqueue(json: Fixtures.listRowEnvelope(id: "row-new")) let service = ListsService(api: api) // When @@ -765,7 +765,7 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenEmptyRowData_whenCreatingRow_thenStillPosts() async throws { // Given — boundary: empty row data; the API accepts it. let api = StubAPIClient() - await api.enqueue(json: Fixtures.listRowObject(id: "row-empty")) + await api.enqueue(json: Fixtures.listRowEnvelope(id: "row-empty")) let service = ListsService(api: api) // When @@ -790,10 +790,10 @@ final class OwnedListsServiceTests: XCTestCase { } } - func test_givenUpdate_whenUpdatingRow_thenPatchesAndMapsResponse() async throws { + func test_givenUpdate_whenUpdatingRow_thenPutsAndMapsResponse() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.listRowObject(id: "row-7")) + await api.enqueue(json: Fixtures.listRowEnvelope(id: "row-7")) let service = ListsService(api: api) // When @@ -806,7 +806,8 @@ final class OwnedListsServiceTests: XCTestCase { // Then XCTAssertEqual(row.id, "row-7") let recorded = await api.recorded - XCTAssertEqual(recorded.first?.method, "PATCH") + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V3). + XCTAssertEqual(recorded.first?.method, "PUT") XCTAssertEqual(recorded.first?.path, "/api/lists/books/data/row-7") } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceWriteTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceWriteTests.swift index a48d8b1..d0edc5d 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceWriteTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceWriteTests.swift @@ -200,7 +200,8 @@ final class SocialServiceWriteTests: XCTestCase { // Then let recorded = await api.recorded - XCTAssertEqual(recorded.first?.method, "POST") + // DELETE, not POST — POST is 405 live (work-consolidation.md §1c · V6). + XCTAssertEqual(recorded.first?.method, "DELETE") XCTAssertEqual(recorded.first?.path, "/api/follow/user-42/remove") } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift index 5ca30d8..15c531f 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift @@ -160,6 +160,26 @@ enum Fixtures { """ } + /// The single-row envelope the live API returns from + /// `GET`/`POST`/`PUT` on `/api/lists/[id]/data[/rowId]`: + /// `{ "message"?: …, "data": { …row… } }` (verified 2026-09-06 — + /// work-consolidation.md §1c · V3). The bare `listRowObject` stays for use + /// inside the paginated `rows` array, which is genuinely a list of bare rows. + static func listRowEnvelope( + id: String, + listId: String = "list-1", + title: String = "Dune", + year: Int = 1965, + message: String? = nil + ) -> String { + let messageJSON = message.map { "\"message\": \"\($0)\",\n " } ?? "" + return """ + { + \(messageJSON)"data": \(listRowObject(id: id, listId: listId, title: title, year: year)) + } + """ + } + /// The paginated row envelope: `{ "rows": [...], "pagination": {...} }`. static func paginatedRows( ids: [String], @@ -476,6 +496,38 @@ enum Fixtures { } /// A single `DocumentFolderDTO` object body. + /// The single-document envelope the live API returns from + /// `GET`/`POST`/`PATCH`/`PUT` on `/api/documents[/id]`: + /// `{ "message"?: …, "document": { … } }` (verified 2026-09-06). The bare + /// `documentObject` stays for use inside list and sync payloads, which + /// really are arrays of bare documents. + static func documentEnvelope( + id: String, + title: String = "Welcome", + content: String? = "# Hello", + folderId: String? = nil, + isPublic: Bool? = false, + updatedAt: String? = createdAtISO, + deleted: Bool? = nil, + message: String? = nil + ) -> String { + let messageJSON = message.map { "\"message\": \"\($0)\",\n " } ?? "" + let object = documentObject( + id: id, + title: title, + content: content, + folderId: folderId, + isPublic: isPublic, + updatedAt: updatedAt, + deleted: deleted + ) + return """ + { + \(messageJSON)"document": \(object) + } + """ + } + static func folderObject( id: String, name: String = "Inbox", @@ -496,6 +548,25 @@ enum Fixtures { """ } + /// The single-folder envelope the live API returns from + /// `GET`/`POST`/`PUT` on `/api/documents/folders[/id]`: + /// `{ "message"?: …, "folder": { … } }` (verified 2026-09-06 — + /// work-consolidation.md §1c · V5). + static func folderEnvelope( + id: String, + name: String = "Inbox", + parentId: String? = nil, + deleted: Bool? = nil, + message: String? = nil + ) -> String { + let messageJSON = message.map { "\"message\": \"\($0)\",\n " } ?? "" + return """ + { + \(messageJSON)"folder": \(folderObject(id: id, name: name, parentId: parentId, deleted: deleted)) + } + """ + } + /// `{ "folders": [...], "pagination": {...} }` envelope for folders. static func paginatedFolders( ids: [String], @@ -577,6 +648,33 @@ enum Fixtures { """ } + /// The single-organization envelope the live API returns from + /// `GET`/`POST`/`PUT` on `/api/organizations[/id]`: + /// `{ "message"?: …, "organization": { … } }` (verified 2026-09-06 — + /// work-consolidation.md §1c · V4). + static func organizationEnvelope( + id: String, + name: String = "Acme", + description: String? = "We make things", + isPublic: Bool? = true, + includeTimestamps: Bool = true, + message: String? = nil + ) -> String { + let messageJSON = message.map { "\"message\": \"\($0)\",\n " } ?? "" + let object = organizationObject( + id: id, + name: name, + description: description, + isPublic: isPublic, + includeTimestamps: includeTimestamps + ) + return """ + { + \(messageJSON)"organization": \(object) + } + """ + } + /// The `{ "data": [...], "pagination": {...} }` envelope for orgs. static func paginatedOrganizations( ids: [String], diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift index eb6e84b..efbcf6c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift @@ -71,6 +71,41 @@ public struct DocumentFolderDTO: Codable, Sendable, Equatable, Identifiable { } } +/// Envelope returned by `POST /api/documents`, `GET /api/documents/[id]`, +/// `PATCH /api/documents/[id]` and `PUT /api/documents/[id]`. +/// +/// VERIFIED live 2026-09-06: all four answer `{ "message"?: …, +/// "document": { ... } }` — **not** a bare `DocumentDTO`. The builders +/// previously decoded the bare DTO, so opening, creating and saving a document +/// each failed at the decoder. Found while probing G27's `PUT` variant; it is +/// the same envelope defect as the folder routes (§1c · V5). +public struct DocumentResponse: Codable, Sendable, Equatable { + public let message: String? + public let document: DocumentDTO + + public init(message: String? = nil, document: DocumentDTO) { + self.message = message + self.document = document + } +} + +/// Envelope returned by `POST /api/documents/folders`, +/// `GET /api/documents/folders/[id]` and `PUT /api/documents/folders/[id]`. +/// +/// VERIFIED live 2026-09-06: all three answer `{ "message"?: …, +/// "folder": { ... } }` — **not** a bare `DocumentFolderDTO`. The builders +/// previously decoded the bare DTO, so folder create, read and rename all +/// failed at the decoder (work-consolidation.md §1c · V5). +public struct DocumentFolderResponse: Codable, Sendable, Equatable { + public let message: String? + public let folder: DocumentFolderDTO + + public init(message: String? = nil, folder: DocumentFolderDTO) { + self.message = message + self.folder = folder + } +} + // MARK: - Sync /// `GET /api/documents/sync` response — the delta payload the diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift index 5ac9b1f..7adba8c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift @@ -229,6 +229,24 @@ public struct ListConnectionsResponse: Codable, Sendable, Equatable { } } +/// Envelope returned by `POST /api/lists/[id]/data` and +/// `PUT /api/lists/[id]/data/[rowId]`. +/// +/// VERIFIED live 2026-09-06: both answer +/// `{ "message": "Row successfully", "data": { …row… } }` — +/// **not** a bare `ListRowDTO`. The builders previously decoded the bare row, +/// so even a request that reached the server failed at the decoder +/// (work-consolidation.md §1c · V3). +public struct ListRowWriteResponse: Codable, Sendable, Equatable { + public let message: String? + public let data: ListRowDTO + + public init(message: String? = nil, data: ListRowDTO) { + self.message = message + self.data = data + } +} + // MARK: - Request bodies /// `POST /api/lists` body. @@ -283,19 +301,38 @@ public struct UpdateListSchemaRequest: Codable, Sendable, Equatable { } } -/// `POST /api/lists/[id]/data` body: `{ "rowData": { ... } }`. +/// `POST /api/lists/[id]/data` body: `{ "data": { ... } }`. +/// +/// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V3): the wire field is +/// **`data`**, not `rowData`. Sending `rowData` returns +/// `400 {"error":"Data is required","code":"bad_request"}`, so row creation +/// never worked against production. The Swift property keeps the `rowData` name +/// — it matches `ListRowDTO.rowData`, which the *response* really does nest +/// under that key — and `CodingKeys` maps it to the wire name. public struct CreateListRowRequest: Codable, Sendable, Equatable { public let rowData: [String: ListJSONValue] + private enum CodingKeys: String, CodingKey { + case rowData = "data" + } + public init(rowData: [String: ListJSONValue]) { self.rowData = rowData } } -/// `PATCH /api/lists/[id]/data/[rowId]` body: partial `{ "rowData": { ... } }`. +/// `PUT /api/lists/[id]/data/[rowId]` body: `{ "data": { ... } }`. +/// +/// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V3): same `data` +/// wire-name correction as `CreateListRowRequest` — a `rowData` body is +/// rejected with `400 "Data is required"` even once the verb is right. public struct UpdateListRowRequest: Codable, Sendable, Equatable { public let rowData: [String: ListJSONValue] + private enum CodingKeys: String, CodingKey { + case rowData = "data" + } + public init(rowData: [String: ListJSONValue]) { self.rowData = rowData } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift index ad4082e..b463054 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift @@ -317,6 +317,68 @@ public struct MessageWriteResponse: Decodable, Sendable, Equatable { /// Cross-post and scheduling fields are all optional and default to `nil`, so /// a plain text post is `CreateMessageRequest(content: "hi")`. Optional fields /// encode only when set (see `encode(to:)`), so the wire body stays minimal. +/// One platform's row in a `POST /api/messages/[id]/reply-counts` refresh +/// (work-consolidation.md G27). +/// +/// VERIFIED live 2026-09-06: a real refresh returned +/// `{"platform":"mastodon","count":0,"status":"success","checkedAt":"…"}` for +/// the connected platforms and `{"platform":"twitter","status":"unsupported", +/// "checkedAt":"…"}` for X — so `count` is **absent** when the platform cannot +/// be polled, and `status` is the field that says whether the number is real. +public struct MessageReplyCountDTO: Codable, Sendable, Equatable { + public let platform: String + /// Absent when `status` is not `"success"` — an unsupported platform + /// reports no number at all rather than a misleading zero. + public let count: Int? + /// `"success"` / `"unsupported"` observed live; treated as an open set. + public let status: String? + public let checkedAt: Date? + + public init(platform: String, count: Int? = nil, status: String? = nil, checkedAt: Date? = nil) { + self.platform = platform + self.count = count + self.status = status + self.checkedAt = checkedAt + } +} + +/// `POST /api/messages/[id]/reply-counts` response — the refreshed per-platform +/// cross-post reply tallies plus the time the sweep ran. +public struct MessageReplyCountsResponse: Codable, Sendable, Equatable { + public let replyCounts: [MessageReplyCountDTO] + public let repliesCheckedAt: Date? + + public init(replyCounts: [MessageReplyCountDTO], repliesCheckedAt: Date? = nil) { + self.replyCounts = replyCounts + self.repliesCheckedAt = repliesCheckedAt + } +} + +/// `PATCH /api/messages/[id]` body — the **only** field the live route honours. +/// +/// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V1). `PATCH` on a +/// message is a **reschedule** route, not a general edit route: +/// • it applies only to a scheduled post whose `scheduledAt` is still in the +/// future — on a already-published message it returns +/// `400 {"error":"Can only edit scheduled posts that are in the future"}`; +/// • `scheduledAt` is the only accepted key. A body of `content`, `title`, +/// `body`, `tags`, `publiclyVisible` or `visibility` — alone or in any +/// combination — returns `400 {"error":"No valid updates provided"}`; +/// • sending `content` *alongside* `scheduledAt` succeeds but the content is +/// **silently discarded** (confirmed: the stored `content` was unchanged +/// while `scheduledAt` moved), which is why this type deliberately cannot +/// express a content edit. +/// The reply is a **bare `MessageDTO`**, not the `MessageWriteResponse` +/// envelope that `POST /api/messages` returns. +public struct RescheduleMessageRequest: Encodable, Sendable, Equatable { + /// The new send time. Must be in the future. + public let scheduledAt: Date + + public init(scheduledAt: Date) { + self.scheduledAt = scheduledAt + } +} + public struct CreateMessageRequest: Encodable, Sendable, Equatable { /// The message body. Markdown source is authored here; the server renders it. public let content: String diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift index 845ed4a..62f1ecb 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OrganizationDTO.swift @@ -91,6 +91,25 @@ public struct OrganizationMembershipResponse: Codable, Sendable, Equatable { } } +/// Envelope returned by `POST /api/organizations` and +/// `PUT /api/organizations/[id]`. +/// +/// VERIFIED live 2026-09-06: both write routes answer with +/// `{ "message": "Organization successfully", +/// "organization": { ... } }` — **not** a bare `OrganizationDTO`. The +/// builders previously decoded the bare DTO, so every organization create and +/// rename failed at the decoder even when the request itself succeeded +/// (work-consolidation.md §1c · V4). +public struct OrganizationWriteResponse: Codable, Sendable, Equatable { + public let message: String? + public let organization: OrganizationDTO + + public init(message: String? = nil, organization: OrganizationDTO) { + self.message = message + self.organization = organization + } +} + // MARK: - OrganizationUserDTO /// A user-with-role row from `GET /api/organizations/[id]/users`. Group-local, diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index 3e2e427..b8c2918 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -3,7 +3,54 @@ import Foundation // MARK: - UserResponse /// Envelope for `GET /api/user` — the live API nests the account under a -/// top-level `user` key: `{ "user": { ... } }`. +/// top-level `user` key: `{ "user": { ... /// One row in the `GET /api/user/engagement` recent feed. Structurally a +/// notification: the aggregate view is built from the same records. +public struct UserEngagementItemDTO: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String? + public let body: String? + public let type: String? + public let sourceMessageId: String? + public let createdAt: Date? + public let routePath: String? + + public init( + id: String, + title: String? = nil, + body: String? = nil, + type: String? = nil, + sourceMessageId: String? = nil, + createdAt: Date? = nil, + routePath: String? = nil + ) { + self.id = id + self.title = title + self.body = body + self.type = type + self.sourceMessageId = sourceMessageId + self.createdAt = createdAt + self.routePath = routePath + } +} + +/// `GET /api/user/engagement` response — lifetime dig / push totals on your own +/// messages plus the most recent engagement events (work-consolidation.md G27). +/// +/// VERIFIED live 2026-09-06: returned +/// `{"totalDigs":24,"totalPushes":6,"recent":[ …10 items… ]}`. +public struct UserEngagementResponse: Codable, Sendable, Equatable { + public let totalDigs: Int + public let totalPushes: Int + public let recent: [UserEngagementItemDTO] + + public init(totalDigs: Int, totalPushes: Int, recent: [UserEngagementItemDTO] = []) { + self.totalDigs = totalDigs + self.totalPushes = totalPushes + self.recent = recent + } +} + +// MARK: - Request bodies }`. public struct UserResponse: Decodable, Sendable, Equatable { public let user: UserDTO diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift index 50f59bb..1a887d9 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift @@ -55,20 +55,40 @@ public enum Documents { } /// `POST /api/documents` - public static func create(_ body: CreateDocumentRequest) -> Request { + /// + /// VERIFIED live 2026-09-06: answers `{ message, document }`, not a bare DTO. + public static func create(_ body: CreateDocumentRequest) -> Request { Request(method: .post, path: "/api/documents", body: .json(body), auth: .bearer) } /// `GET /api/documents/[id]` - public static func get(id: String) -> Request { + /// + /// VERIFIED live 2026-09-06: answers `{ "document": { … } }` (no `message` + /// on the read), not a bare DTO. + public static func get(id: String) -> Request { Request(method: .get, path: "/api/documents/\(id)", auth: .bearer) } - /// `PATCH /api/documents/[id]` - public static func update(id: String, _ body: UpdateDocumentRequest) -> Request { + /// `PATCH /api/documents/[id]` — merge the supplied fields only. + /// + /// VERIFIED live 2026-09-06: answers `{ message, document }`, not a bare + /// DTO. A title-only `PATCH` left `content` intact, confirming it is a true + /// partial update. + public static func update(id: String, _ body: UpdateDocumentRequest) -> Request { Request(method: .patch, path: "/api/documents/\(id)", body: .json(body), auth: .bearer) } + /// `PUT /api/documents/[id]` — full replace (work-consolidation.md G27). + /// + /// VERIFIED live 2026-09-06: `OPTIONS` reports + /// `Allow: DELETE, GET, HEAD, OPTIONS, PATCH, PUT`, and a live `PUT` + /// carrying `title` + `content` replaced both and returned the same + /// `{ message, document }` envelope as `PATCH`. Use `update` for a partial + /// edit; this is the whole-document variant. + public static func replace(id: String, _ body: UpdateDocumentRequest) -> Request { + Request(method: .put, path: "/api/documents/\(id)", body: .json(body), auth: .bearer) + } + /// `DELETE /api/documents/[id]` public static func delete(id: String) -> Request { Request(method: .delete, path: "/api/documents/\(id)", auth: .bearer) @@ -110,21 +130,32 @@ public enum Documents { } /// `POST /api/documents/folders` - public static func createFolder(_ body: CreateDocumentFolderRequest) -> Request { + /// VERIFIED live 2026-09-06: answers the `{ message, folder }` envelope, + /// not a bare `DocumentFolderDTO`. + public static func createFolder(_ body: CreateDocumentFolderRequest) -> Request { Request(method: .post, path: "/api/documents/folders", body: .json(body), auth: .bearer) } /// `GET /api/documents/folders/[id]` - public static func folder(id: String) -> Request { + /// + /// VERIFIED live 2026-09-06: answers `{ "folder": { … } }` (no `message` on + /// the read). Was decoding a bare DTO, so folder detail never loaded. + public static func folder(id: String) -> Request { Request(method: .get, path: "/api/documents/folders/\(id)", auth: .bearer) } - /// `PATCH /api/documents/folders/[id]` + /// `PUT /api/documents/folders/[id]` — rename or re-parent a folder. + /// + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V5): the verb is + /// `PUT`. `OPTIONS` reports `Allow: DELETE, GET, HEAD, OPTIONS, PUT` and the + /// `PATCH` this shipped with returns **405**. A live `PUT` renamed a probe + /// folder and returned HTTP 200 with the `{ message, folder }` envelope, so + /// the response type is corrected alongside the verb. public static func updateFolder( id: String, _ body: UpdateDocumentFolderRequest - ) -> Request { - Request(method: .patch, path: "/api/documents/folders/\(id)", body: .json(body), auth: .bearer) + ) -> Request { + Request(method: .put, path: "/api/documents/folders/\(id)", body: .json(body), auth: .bearer) } /// `DELETE /api/documents/folders/[id]` diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/FollowEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/FollowEndpoint.swift index 6d57a1a..c5be793 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/FollowEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/FollowEndpoint.swift @@ -110,9 +110,19 @@ public enum Follow { Request(method: .post, path: "/api/follow/\(userId)/reject", auth: .bearer) } - /// `POST /api/follow/[userId]/remove` + /// `DELETE /api/follow/[userId]/remove` — drop a follower of *this* account. + /// + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V6): the verb is + /// `DELETE`. `OPTIONS` reports `Allow: DELETE, OPTIONS` and the `POST` this + /// shipped with returns **405**. A live `DELETE` against a non-follower + /// reached the handler and returned a business-level + /// `404 {"error":"Follower relationship not found","code":"not_found"}`, + /// which proves the route and verb; the **success** envelope could not be + /// exercised because the test account has no followers, so + /// `FollowActionResponse` is deliberately left tolerant rather than + /// tightened against an unobserved body. public static func remove(userId: String) -> Request { - Request(method: .post, path: "/api/follow/\(userId)/remove", auth: .bearer) + Request(method: .delete, path: "/api/follow/\(userId)/remove", auth: .bearer) } /// `GET /api/follow/requests` — pending inbound follow requests under the diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift index 623863b..e1bd88f 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift @@ -19,8 +19,10 @@ import Foundation /// (the nested `/repos/{repo}/issues` form 404s); /// • `repos`, `assignees`, `labels`, `next-issue-number` are NESTED under /// `/api/github/repos/{repo}/…` (confirmed present). -/// • issue **update** (`updateIssue`) and **comment** routes are NOT the paths -/// below — see their ⚠️ notes and work-consolidation.md §2 · P1-H2. +/// • single-issue **update** and **comment** are FLAT too, but on a different +/// shape again — `/api/github/issues/{owner}/{repo}/{number}[/comments]` +/// (verified live 2026-09-06, work-consolidation.md §1c · V7). Note that +/// `updateIssue` only sets labels/assignees; see its doc comment. /// Response shapes are decoded tolerantly (see `GitHubDTO.swift`). Auth: all `.bearer`. public enum GitHub { @@ -65,16 +67,25 @@ public enum GitHub { ) } - /// `PATCH /api/github/repos/{owner}/{repo}/issues/{number}` — edit an - /// issue's title/body/state/labels/assignees (only the supplied fields). + /// `PATCH /api/github/issues/{owner}/{repo}/{number}` — set an issue's + /// **labels and/or assignees**. /// - /// ⚠️ ROUTE UNVERIFIED — LIKELY BROKEN (2026-08-17). This nested path 404s - /// live, and the flat `/api/github/issues` collection rejects `PATCH`/`PUT` - /// (405; `Allow: GET, HEAD, OPTIONS, POST`). The correct update route/verb - /// could not be found by probing (all obvious single-issue paths 404). Until - /// backend confirmation (work-consolidation.md §2 · P1-H2), close/reopen and - /// label/assignee editing will NOT work against the live API. Left as-is so - /// the shape is documented rather than silently guessed. + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V7). The route is + /// **flat** — `{owner}/{repo}/{number}` hang off `/api/github/issues`, they + /// are not nested under `/api/github/repos`. `OPTIONS` reports + /// `Allow: OPTIONS, PATCH`; the nested path this shipped with **404s**. + /// + /// - Important: despite the name, this is **not** a general issue editor. + /// The handler requires `labels` or `assignees` to be present and answers + /// `400 {"error":"labels or assignees required"}` to a body of `state`, + /// `title` or `body` alone. With `labels`/`assignees` present the request + /// is accepted and proxied to GitHub (the probe account got a + /// `403 "Must have admin rights to Repository."` from GitHub itself on a + /// repo it does not own — which confirms the route, the verb and the body + /// shape). **Close / reopen and title/body edits therefore still have no + /// live route**; `GitHubService.updateIssue` rejects those up front. + /// A 200 body could not be captured because the test account has no + /// admin-rights repository, so `GitHubIssueResponse` is left tolerant. public static func updateIssue( repo: String, number: Int, @@ -82,18 +93,22 @@ public enum GitHub { ) -> Request { Request( method: .patch, - path: "/api/github/repos/\(repo)/issues/\(number)", + path: "/api/github/issues/\(repo)/\(number)", body: .json(body), auth: .bearer ) } - /// `POST /api/github/repos/{owner}/{repo}/issues/{number}/comments`. + /// `POST /api/github/issues/{owner}/{repo}/{number}/comments` — comment on + /// an issue. /// - /// ⚠️ ROUTE UNVERIFIED — LIKELY BROKEN (2026-08-17). This path 404s live and - /// no comment route was found (`/api/github/issues/{n}/comments`, - /// `/api/github/issues/comments`, `/api/github/comments` all 404). Needs - /// backend confirmation (work-consolidation.md §2 · P1-H2). + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V7): the flat path + /// is correct and `OPTIONS` reports `Allow: OPTIONS, POST`; the nested path + /// this shipped with **404s**. A live `POST` reached GitHub's own comment + /// logic (it came back `403 "Commenting is disabled on issues with more + /// than 2500 comments"` for the public `octocat/hello-world#1`), proving the + /// route, verb and `{ "body": … }` payload. As above, a 200 body was not + /// capturable from the test account, so the decoder stays tolerant. public static func comment( repo: String, number: Int, @@ -101,7 +116,7 @@ public enum GitHub { ) -> Request { Request( method: .post, - path: "/api/github/repos/\(repo)/issues/\(number)/comments", + path: "/api/github/issues/\(repo)/\(number)/comments", body: .json(body), auth: .bearer ) diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift index 3b3d02d..31eac9c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift @@ -96,23 +96,44 @@ public enum Lists { ) } - /// `POST /api/lists/[id]/data` - public static func createRow(listId: String, _ body: CreateListRowRequest) -> Request { + /// `POST /api/lists/[id]/data` — append a row. + /// + /// VERIFIED live 2026-09-06: answers the `{ message, data }` envelope, not a + /// bare `ListRowDTO`. See `CreateListRowRequest` for the matching `data` + /// request-field correction — both were wrong, so row creation failed twice + /// over (work-consolidation.md §1c · V3). + public static func createRow(listId: String, _ body: CreateListRowRequest) -> Request { Request(method: .post, path: "/api/lists/\(listId)/data", body: .json(body), auth: .bearer) } - /// `GET /api/lists/[id]/data/[rowId]` - public static func row(listId: String, rowId: String) -> Request { + /// `GET /api/lists/[id]/data/[rowId]` — one row. + /// + /// VERIFIED live 2026-09-06: answers `{ "data": { …row… } }` (no `message` + /// on the read), sharing the `ListRowWriteResponse` envelope. Was decoding a + /// bare `ListRowDTO`, so the row inspector never loaded a row. + public static func row(listId: String, rowId: String) -> Request { Request(method: .get, path: "/api/lists/\(listId)/data/\(rowId)", auth: .bearer) } - /// `PATCH /api/lists/[id]/data/[rowId]` + /// `PUT /api/lists/[id]/data/[rowId]` — replace a row's cell values. + /// + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V3). This call was + /// wrong in **three** ways at once, all fixed here and each confirmed + /// against the test account: + /// 1. **Verb** — `OPTIONS` reports `Allow: DELETE, GET, HEAD, OPTIONS, PUT` + /// and the shipped `PATCH` returns **405**. + /// 2. **Request field** — the body is `{ "data": … }`; the shipped + /// `{ "rowData": … }` returns `400 "Data is required"` even on `PUT`. + /// 3. **Response shape** — the reply is `{ message, data }`, not a bare + /// `ListRowDTO`. + /// A live `PUT` carrying the corrected body returned HTTP 200 and the + /// updated row. public static func updateRow( listId: String, rowId: String, _ body: UpdateListRowRequest - ) -> Request { - Request(method: .patch, path: "/api/lists/\(listId)/data/\(rowId)", body: .json(body), auth: .bearer) + ) -> Request { + Request(method: .put, path: "/api/lists/\(listId)/data/\(rowId)", body: .json(body), auth: .bearer) } /// `DELETE /api/lists/[id]/data/[rowId]` diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift index 54d89f2..ca9d443 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift @@ -88,14 +88,35 @@ public enum Messages { Request(method: .post, path: "/api/messages", body: .json(body), auth: .bearer) } - /// `PUT /api/messages/[id]` — edit an existing message. + /// `PATCH /api/messages/[id]` — move a **future scheduled** post to a new + /// send time. Returns a bare `MessageDTO`. /// - /// Uses the same `MessageWriteResponse` wrapper as `create` for symmetry. - /// NOTE: live `PUT /api/messages/[id]` currently returns **HTTP 405** - /// (observed 2026-08-17) — the edit method drifted and needs a backend - /// confirmation of the correct verb (see work-consolidation.md §2 · P2-I). - public static func update(id: String, _ body: CreateMessageRequest) -> Request { - Request(method: .put, path: "/api/messages/\(id)", body: .json(body), auth: .bearer) + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V1). This replaces + /// the old `update(id:_:)`, which sent `PUT` (**405** live) and decoded the + /// `MessageWriteResponse` envelope (the reply is a bare DTO). The rename is + /// deliberate and is the substantive half of the finding: `PATCH` is a + /// **reschedule** route, not a message-edit route. The live handler accepts + /// only `scheduledAt`, rejects a content/tags/visibility body with + /// `400 "No valid updates provided"`, silently drops `content` when it is + /// sent alongside `scheduledAt`, and refuses any already-published message + /// with `400 "Can only edit scheduled posts that are in the future"`. + /// See `RescheduleMessageRequest` for the probe transcript. + /// + /// Editing a published message has **no route on the live API** — see + /// `MessagesServicing.update` for how that is surfaced to callers. + public static func reschedule(id: String, _ body: RescheduleMessageRequest) -> Request { + Request(method: .patch, path: "/api/messages/\(id)", body: .json(body), auth: .bearer) + } + + /// `POST /api/messages/[id]/reply-counts` — re-poll the cross-post targets + /// and return fresh per-platform reply tallies (work-consolidation.md G27). + /// + /// VERIFIED live 2026-09-06: `OPTIONS` reports `Allow: OPTIONS, POST`, and a + /// real refresh on an owned message returned HTTP 200 with + /// `{ replyCounts: [...], repliesCheckedAt }`. The body is empty — the id in + /// the path is the whole request. + public static func refreshReplyCounts(id: String) -> Request { + Request(method: .post, path: "/api/messages/\(id)/reply-counts", auth: .bearer) } /// `DELETE /api/messages/[id]` — delete a message. The body is not diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift index 3bf1fae..006170c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift @@ -36,4 +36,15 @@ public enum Notifications { public static func markAllRead() -> Request { Request(method: .post, path: "/api/notifications/mark-all-read", auth: .bearer) } + + /// `DELETE /api/notifications/[id]` — remove a single notification from the + /// tray (work-consolidation.md G27). The client could previously only mark + /// one read. + /// + /// VERIFIED live 2026-09-06: `OPTIONS` reports `Allow: DELETE, OPTIONS` and + /// a real `DELETE` returned **204 No Content** — so there is no body to + /// decode. Send with `sendVoid`. + public static func delete(id: String) -> Request { + Request(method: .delete, path: "/api/notifications/\(id)", auth: .bearer) + } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift index ae1ea10..3901c7e 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/OrganizationsEndpoint.swift @@ -37,19 +37,34 @@ public enum Organizations { ) } - /// `POST /api/organizations` - public static func create(_ body: CreateOrganizationRequest) -> Request { + /// `POST /api/organizations` — create an organization. + /// + /// VERIFIED live 2026-09-06: answers `{ "message": …, "organization": { … } }`, + /// so this decodes `OrganizationWriteResponse`. It previously decoded a bare + /// `OrganizationDTO` and therefore failed on every successful create. + public static func create(_ body: CreateOrganizationRequest) -> Request { Request(method: .post, path: "/api/organizations", body: .json(body), auth: .bearer) } - /// `GET /api/organizations/[id]` - public static func get(id: String) -> Request { + /// `GET /api/organizations/[id]` — one organization. + /// + /// VERIFIED live 2026-09-06: answers `{ "organization": { … } }`, the same + /// envelope as the write routes (no `message` key on the read). Was decoding + /// a bare `OrganizationDTO`, so organization detail never loaded. + public static func get(id: String) -> Request { Request(method: .get, path: "/api/organizations/\(id)", auth: .bearer) } - /// `PATCH /api/organizations/[id]` - public static func update(id: String, _ body: UpdateOrganizationRequest) -> Request { - Request(method: .patch, path: "/api/organizations/\(id)", body: .json(body), auth: .bearer) + /// `PUT /api/organizations/[id]` — rename / re-describe / re-scope an org. + /// + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V4): the verb is + /// `PUT`. `OPTIONS` reports `Allow: DELETE, GET, HEAD, OPTIONS, PUT` and the + /// `PATCH` this shipped with returns **405**. A live `PUT` renamed a probe + /// organization and returned HTTP 200 with the `OrganizationWriteResponse` + /// envelope — the response type is corrected here too, since fixing only the + /// verb would have swapped a 405 for a decode failure. + public static func update(id: String, _ body: UpdateOrganizationRequest) -> Request { + Request(method: .put, path: "/api/organizations/\(id)", body: .json(body), auth: .bearer) } // MARK: - Members diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift index b16c291..615ecf9 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift @@ -30,13 +30,35 @@ public enum User { Request(method: .get, path: "/api/user/organizations", auth: .session) } + /// `GET /api/user/engagement` — lifetime dig/push totals on your own + /// messages, plus a recent-events feed (work-consolidation.md G27). + /// + /// **Session-only, and that is now settled.** The 2026-09-05 pass saw a 401 + /// under Bearer and left it flagged "confirm before building". Re-probed + /// 2026-09-06: `OPTIONS` reports `Allow: GET, HEAD, OPTIONS`, Bearer still + /// returns `401 {"error":"Unauthorized","code":"unauthorized"}`, and the + /// same request over a cookie session returns **HTTP 200** with the totals. + /// So the route is real and reachable — it just does not accept the bearer + /// token, hence `auth: .session` (the transport establishes the cookie + /// lazily, per decision 0001). + public static func engagement() -> Request { + Request(method: .get, path: "/api/user/engagement", auth: .session) + } + // MARK: - Write - /// `POST /api/user/update` — patch profile / preference fields. Returns the + /// `PATCH /api/user/update` — patch profile / preference fields. Returns the /// updated account under the same `{ "user": { ... } }` envelope as - /// `current()`. + /// `current()` (the live body is `{ "message": "User updated successfully", + /// "user": { ... } }`; the extra `message` key is ignored). + /// + /// VERIFIED live 2026-09-06 (work-consolidation.md §1c · V2): the verb is + /// `PATCH`. `OPTIONS` reports `Allow: OPTIONS, PATCH` and the `POST` this + /// shipped with returns **405**, so Settings ▸ Preferences could never save + /// against production. Confirmed end-to-end with a real `PATCH` on the test + /// account, which returned HTTP 200 and the envelope above. public static func update(_ body: UpdateUserRequest) -> Request { - Request(method: .post, path: "/api/user/update", body: .json(body), auth: .bearer) + Request(method: .patch, path: "/api/user/update", body: .json(body), auth: .bearer) } /// `POST /api/user/avatar/upload` — upload avatar image bytes and receive diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift index 5b54d7b..b357024 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift @@ -155,4 +155,66 @@ final class ContractTests: XCTestCase { "thread otherUser must match the requested username" ) } + // MARK: - §1c live-verb defects (V1–V7) + + /// The drift alarm for the verb fixes made on 2026-09-06. + /// + /// Each of these routes shipped with a verb the live server rejects, so the + /// feature behind it failed in production. This asserts the corrected verb + /// is still the one the server advertises, by reading the `Allow` header + /// from a real authenticated `OPTIONS` — the same evidence the fixes were + /// built on. It is read-only: `OPTIONS` mutates nothing, so this is safe to + /// run against the live account on every CI pass. + /// + /// A failure means the live API moved again. Re-probe before editing the + /// expectation, and fix the builder rather than this test. + func test_givenLiveCredentials_whenOptioningFixedRoutes_thenAllowHeadersStillMatch() async throws { + guard let credentials = credentialsFromEnvironment() else { + throw XCTSkip("Live credentials not set — skipping contract test.") + } + + let store = InMemoryTokenStore() + let (_, service) = makeLiveStack(tokenStore: store) + let token = try await service.signIn( + email: credentials.email, + password: credentials.password + ) + + // (path, the verb the client now sends). Ids are placeholders — the + // route table answers OPTIONS without resolving the resource. + let expectations: [(path: String, verb: String)] = [ + ("/api/messages/probe", "PATCH"), // V1 + ("/api/user/update", "PATCH"), // V2 + ("/api/lists/probe/data/probe", "PUT"), // V3 + ("/api/organizations/probe", "PUT"), // V4 + ("/api/documents/folders/probe", "PUT"), // V5 + ("/api/follow/probe/remove", "DELETE"), // V6 + ("/api/github/issues/owner/repo/1", "PATCH"), // V7 + ("/api/github/issues/owner/repo/1/comments", "POST") // V7 + ] + + for expectation in expectations { + var request = URLRequest(url: liveBaseURL.appendingPathComponent(expectation.path)) + request.httpMethod = "OPTIONS" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + let http = try XCTUnwrap(response as? HTTPURLResponse) + let allow = try XCTUnwrap( + http.value(forHTTPHeaderField: "Allow"), + "no Allow header for \(expectation.path)" + ) + + let verbs = Set( + allow.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces).uppercased() + } + ) + XCTAssertTrue( + verbs.contains(expectation.verb), + "\(expectation.path) no longer allows \(expectation.verb) — live Allow is \(allow)" + ) + } + } + } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsEndpointTests.swift index 09d0a44..35a77fe 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsEndpointTests.swift @@ -43,7 +43,8 @@ final class DocumentsEndpointTests: XCTestCase { XCTAssertEqual(Documents.folders().paginationKey, "folders") XCTAssertEqual(Documents.createFolder(CreateDocumentFolderRequest(name: "n")).method, .post) XCTAssertEqual(Documents.folder(id: "f1").path, "/api/documents/folders/f1") - XCTAssertEqual(Documents.updateFolder(id: "f1", UpdateDocumentFolderRequest(name: "n")).method, .patch) + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V5). + XCTAssertEqual(Documents.updateFolder(id: "f1", UpdateDocumentFolderRequest(name: "n")).method, .put) XCTAssertEqual(Documents.deleteFolder(id: "f1").method, .delete) XCTAssertEqual(Documents.folderDocuments(id: "f1").path, "/api/documents/folders/f1/documents") XCTAssertEqual(Documents.folderDocuments(id: "f1").paginationKey, "documents") diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/GitHubEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/GitHubEndpointTests.swift index 043ab2e..8145f02 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/GitHubEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/GitHubEndpointTests.swift @@ -45,15 +45,15 @@ final class GitHubEndpointTests: XCTestCase { XCTAssertEqual(create.method, .post) XCTAssertEqual(create.query.first(where: { $0.name == "repo" })?.value, "octocat/hello") - // updateIssue / comment paths remain the (live-404) nested form — the - // correct routes are unknown (see GitHubEndpoint ⚠️ notes / P1-H2). These - // assertions lock the current builder output, not a verified live route. - let update = GitHub.updateIssue(repo: "octocat/hello", number: 42, UpdateGitHubIssueRequest(state: "closed")) - XCTAssertEqual(update.path, "/api/github/repos/octocat/hello/issues/42") + // updateIssue / comment are FLAT under /api/github/issues — verified live + // 2026-09-06 (work-consolidation.md §1c · V7). The nested form these + // previously used 404s. + let update = GitHub.updateIssue(repo: "octocat/hello", number: 42, UpdateGitHubIssueRequest(labels: ["bug"])) + XCTAssertEqual(update.path, "/api/github/issues/octocat/hello/42") XCTAssertEqual(update.method, .patch) let comment = GitHub.comment(repo: "octocat/hello", number: 42, CreateGitHubCommentRequest(body: "hi")) - XCTAssertEqual(comment.path, "/api/github/repos/octocat/hello/issues/42/comments") + XCTAssertEqual(comment.path, "/api/github/issues/octocat/hello/42/comments") XCTAssertEqual(comment.method, .post) XCTAssertEqual(GitHub.assignees(repo: "octocat/hello").path, "/api/github/repos/octocat/hello/assignees") diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift index a98df00..8931377 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift @@ -47,7 +47,8 @@ final class ListsEndpointTests: XCTestCase { XCTAssertEqual(Lists.rows(listId: "7").paginationKey, "rows") XCTAssertEqual(Lists.createRow(listId: "7", CreateListRowRequest(rowData: [:])).method, .post) XCTAssertEqual(Lists.row(listId: "7", rowId: "r1").path, "/api/lists/7/data/r1") - XCTAssertEqual(Lists.updateRow(listId: "7", rowId: "r1", UpdateListRowRequest(rowData: [:])).method, .patch) + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V3). + XCTAssertEqual(Lists.updateRow(listId: "7", rowId: "r1", UpdateListRowRequest(rowData: [:])).method, .put) XCTAssertEqual(Lists.deleteRow(listId: "7", rowId: "r1").method, .delete) XCTAssertEqual(Lists.watchers(listId: "7").path, "/api/lists/7/watchers") @@ -120,12 +121,14 @@ final class ListsEndpointTests: XCTestCase { func test_givenDynamicSchemaRow_whenRowSent_thenDecodesFlexibleRowData() async throws { let (client, transport) = makeClient() + // The live single-row read is enveloped under `data` (verified + // 2026-09-06 — work-consolidation.md §1c · V3). await transport.enqueue(.json(#""" - {"id":"r1","listId":"7", - "rowData":{"Title":"Dune","Year":1965,"Read":true,"Rating":4.5,"Tags":["sci-fi"]}} + {"data":{"id":"r1","listId":"7", + "rowData":{"Title":"Dune","Year":1965,"Read":true,"Rating":4.5,"Tags":["sci-fi"]}}} """#)) - let row = try await client.send(Lists.row(listId: "7", rowId: "r1")) + let row = try await client.send(Lists.row(listId: "7", rowId: "r1")).data XCTAssertEqual(row.id, "r1") XCTAssertEqual(row.rowData["Title"], .string("Dune")) @@ -144,11 +147,11 @@ final class ListsEndpointTests: XCTestCase { func test_givenGitHubSyncedRow_whenRowSent_thenDecodesSourceAndRepo() async throws { let (client, transport) = makeClient() await transport.enqueue(.json(#""" - {"id":"r1","listId":"7","rowData":{"Title":"Fix crash"}, - "source":"github","githubRepo":"CompositeCode/interlinedlist"} + {"data":{"id":"r1","listId":"7","rowData":{"Title":"Fix crash"}, + "source":"github","githubRepo":"CompositeCode/interlinedlist"}} """#)) - let row = try await client.send(Lists.row(listId: "7", rowId: "r1")) + let row = try await client.send(Lists.row(listId: "7", rowId: "r1")).data XCTAssertEqual(row.source, "github") XCTAssertEqual(row.githubRepo, "CompositeCode/interlinedlist") @@ -158,26 +161,32 @@ final class ListsEndpointTests: XCTestCase { // failure, so existing (non-GitHub) rows keep decoding unchanged. func test_givenNativeRow_whenRowSent_thenSourceAndRepoAreNil() async throws { let (client, transport) = makeClient() - await transport.enqueue(.json(#"{"id":"r1","listId":"7","rowData":{"Title":"Dune"}}"#)) + await transport.enqueue(.json(#"{"data":{"id":"r1","listId":"7","rowData":{"Title":"Dune"}}}"#)) - let row = try await client.send(Lists.row(listId: "7", rowId: "r1")) + let row = try await client.send(Lists.row(listId: "7", rowId: "r1")).data XCTAssertNil(row.source) XCTAssertNil(row.githubRepo) } - func test_givenRowData_whenCreateRowSent_thenEncodesRowDataEnvelope() async throws { + // The live create takes `{ "data": … }` on the wire and answers + // `{ message, data }` (verified 2026-09-06 — work-consolidation.md §1c · V3). + // The old fixture asserted a `rowData` request key, which the server rejects + // with 400 "Data is required". + func test_givenRowData_whenCreateRowSent_thenEncodesUnderDataKey() async throws { let (client, transport) = makeClient() - await transport.enqueue(.json(#"{"id":"r9","rowData":{"Title":"New"}}"#)) + await transport.enqueue(.json(#"{"message":"Row created successfully","data":{"id":"r9","rowData":{"Title":"New"}}}"#)) let body = CreateListRowRequest(rowData: ["Title": .string("New")]) - _ = try await client.send(Lists.createRow(listId: "7", body)) + let created = try await client.send(Lists.createRow(listId: "7", body)) + XCTAssertEqual(created.data.id, "r9") let received = await transport.received XCTAssertEqual(received[0].httpMethod, "POST") let sent = try XCTUnwrap(received[0].httpBody) let decoded = try JSONDecoder().decode([String: [String: ListJSONValue]].self, from: sent) - XCTAssertEqual(decoded["rowData"]?["Title"], .string("New")) + XCTAssertEqual(decoded["data"]?["Title"], .string("New")) + XCTAssertNil(decoded["rowData"]) } func test_givenConnectionsEnvelope_whenConnectionsSent_thenDecodesUnderConnectionsKey() async throws { @@ -222,9 +231,9 @@ final class ListsEndpointTests: XCTestCase { func test_givenEmptyRowData_whenRowSent_thenDecodesEmptyMap() async throws { let (client, transport) = makeClient() - await transport.enqueue(.json(#"{"id":"r1","rowData":{}}"#)) + await transport.enqueue(.json(#"{"data":{"id":"r1","rowData":{}}}"#)) - let row = try await client.send(Lists.row(listId: "7", rowId: "r1")) + let row = try await client.send(Lists.row(listId: "7", rowId: "r1")).data XCTAssertTrue(row.rowData.isEmpty) } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/LiveVerbDefectRegressionTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/LiveVerbDefectRegressionTests.swift new file mode 100644 index 0000000..f37f0d6 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/LiveVerbDefectRegressionTests.swift @@ -0,0 +1,260 @@ +import XCTest +@testable import InterlinedKit + +/// Regression lock for the **live-verb defects** catalogued as +/// `work-consolidation.md` §1c · V1–V7 and fixed on 2026-09-06. +/// +/// Every one of these calls shipped sending an HTTP verb (or, in V3 and V7, a +/// path and body shape) the production API does not accept, so the feature +/// behind each failed against `https://interlinedlist.com`. Each fix here was +/// confirmed twice before it was written: the live `OPTIONS` `Allow` header, +/// and an authenticated round trip against the `.env` test account. +/// +/// The `Allow` headers observed on 2026-09-06 — the reason each expectation +/// below is what it is: +/// +/// | Route | Live `Allow` | +/// | -------------------------------------------- | ------------------------------------- | +/// | `/api/messages/{id}` | `DELETE, GET, HEAD, OPTIONS, PATCH` | +/// | `/api/user/update` | `OPTIONS, PATCH` | +/// | `/api/lists/{id}/data/{rowId}` | `DELETE, GET, HEAD, OPTIONS, PUT` | +/// | `/api/organizations/{id}` | `DELETE, GET, HEAD, OPTIONS, PUT` | +/// | `/api/documents/folders/{id}` | `DELETE, GET, HEAD, OPTIONS, PUT` | +/// | `/api/follow/{userId}/remove` | `DELETE, OPTIONS` | +/// | `/api/github/issues/{o}/{r}/{n}` | `OPTIONS, PATCH` | +/// | `/api/github/issues/{o}/{r}/{n}/comments` | `OPTIONS, POST` | +/// +/// These tests exist so a future refactor cannot quietly reintroduce a verb the +/// server rejects. A failure here means the client has drifted off the live API +/// again — re-probe before changing an expectation. +final class LiveVerbDefectRegressionTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport() + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - The verb matrix (happy path for all seven at once) + + /// The single assertion that would have caught every §1c defect: each + /// builder emits the verb the live route actually allows. + func test_givenEveryFixedBuilder_whenConstructed_thenSendsTheLiveAllowedVerb() { + XCTAssertEqual( + Messages.reschedule(id: "m1", RescheduleMessageRequest(scheduledAt: .distantFuture)).method, + .patch, "V1 — PUT is 405 live" + ) + XCTAssertEqual( + User.update(UpdateUserRequest(displayName: "Ada")).method, + .patch, "V2 — POST is 405 live" + ) + XCTAssertEqual( + Lists.updateRow(listId: "l1", rowId: "r1", UpdateListRowRequest(rowData: [:])).method, + .put, "V3 — PATCH is 405 live" + ) + XCTAssertEqual( + Organizations.update(id: "o1", UpdateOrganizationRequest(name: "Acme")).method, + .put, "V4 — PATCH is 405 live" + ) + XCTAssertEqual( + Documents.updateFolder(id: "f1", UpdateDocumentFolderRequest(name: "Docs")).method, + .put, "V5 — PATCH is 405 live" + ) + XCTAssertEqual( + Follow.remove(userId: "u1").method, + .delete, "V6 — POST is 405 live" + ) + XCTAssertEqual( + GitHub.updateIssue(repo: "o/r", number: 1, UpdateGitHubIssueRequest(labels: ["bug"])).method, + .patch, "V7 — verb was already right; the path was not" + ) + } + + /// V7's other half: both single-issue routes are flat under + /// `/api/github/issues`, not nested under `/api/github/repos`. + func test_givenGitHubSingleIssueBuilders_whenConstructed_thenUseFlatPaths() { + XCTAssertEqual( + GitHub.updateIssue(repo: "octocat/hello", number: 42, UpdateGitHubIssueRequest(labels: ["bug"])).path, + "/api/github/issues/octocat/hello/42" + ) + XCTAssertEqual( + GitHub.comment(repo: "octocat/hello", number: 42, CreateGitHubCommentRequest(body: "hi")).path, + "/api/github/issues/octocat/hello/42/comments" + ) + } + + // MARK: - Wire-level verbs (the request that actually leaves the client) + + func test_givenFixedWrites_whenSent_thenWireMethodMatchesTheLiveAllowHeader() async throws { + // Each pair is (enqueued live-shaped body, the send). Asserted on the + // URLRequest, not just the builder, so a transport-level regression is + // caught too. + let (client, transport) = makeClient() + + // The live 2026-09-06 body, with the account fields `UserDTO` requires. + await transport.enqueue(.json(#""" + {"message":"User updated successfully", + "user":{"id":"u1","email":"ada@example.com","username":"ada","displayName":"Ada", + "avatar":null,"bio":"hi","theme":"light","emailVerified":true, + "maxMessageLength":5000,"defaultPubliclyVisible":true,"messagesPerPage":25, + "viewingPreference":"all","showPreviews":true,"showAdvancedPostSettings":false, + "latitude":null,"longitude":null,"isPrivateAccount":false, + "githubDefaultRepo":null,"customerStatus":"subscriber","stripeCustomerId":null, + "notificationTrayLimit":20,"createdAt":"2026-01-01T00:00:00.000Z"}} + """#)) + _ = try await client.send(User.update(UpdateUserRequest(displayName: "Ada"))) + + await transport.enqueue(.json(#"{"message":"Row updated successfully","data":{"id":"r1","rowData":{}}}"#)) + _ = try await client.send(Lists.updateRow(listId: "l1", rowId: "r1", UpdateListRowRequest(rowData: [:]))) + + await transport.enqueue(.json(#"{"message":"Organization updated successfully","organization":{"id":"o1","name":"Acme"}}"#)) + _ = try await client.send(Organizations.update(id: "o1", UpdateOrganizationRequest(name: "Acme"))) + + await transport.enqueue(.json(#"{"message":"Folder updated successfully","folder":{"id":"f1","name":"Docs"}}"#)) + _ = try await client.send(Documents.updateFolder(id: "f1", UpdateDocumentFolderRequest(name: "Docs"))) + + let received = await transport.received + XCTAssertEqual(received.map(\.httpMethod), ["PATCH", "PUT", "PUT", "PUT"]) + } + + // MARK: - Response envelopes (fixing the verb alone was not enough) + + /// V3/V4/V5 each answer an envelope, not a bare DTO. Before the fix a + /// corrected verb would simply have swapped a 405 for a decode failure. + func test_givenLiveEnvelopes_whenWritesSent_thenDecodeIntoTheWrappedDTO() async throws { + let (client, transport) = makeClient() + + await transport.enqueue(.json(#"{"message":"Row updated successfully","data":{"id":"r1","listId":"l1","rowData":{"Title":"Dune"}}}"#)) + let row = try await client.send(Lists.updateRow(listId: "l1", rowId: "r1", UpdateListRowRequest(rowData: [:]))) + XCTAssertEqual(row.data.id, "r1") + XCTAssertEqual(row.data.rowData["Title"], .string("Dune")) + + await transport.enqueue(.json(#"{"message":"Organization updated successfully","organization":{"id":"o1","name":"Acme","isPublic":false}}"#)) + let org = try await client.send(Organizations.update(id: "o1", UpdateOrganizationRequest(name: "Acme"))) + XCTAssertEqual(org.organization.name, "Acme") + + await transport.enqueue(.json(#"{"message":"Folder updated successfully","folder":{"id":"f1","name":"Docs"}}"#)) + let folder = try await client.send(Documents.updateFolder(id: "f1", UpdateDocumentFolderRequest(name: "Docs"))) + XCTAssertEqual(folder.folder.name, "Docs") + } + + /// The reads share the same envelopes but omit `message` — so the wrapper's + /// `message` must stay optional. + func test_givenReadEnvelopesWithoutMessage_whenSent_thenStillDecode() async throws { + let (client, transport) = makeClient() + + await transport.enqueue(.json(#"{"organization":{"id":"o1","name":"Acme"}}"#)) + let org = try await client.send(Organizations.get(id: "o1")) + XCTAssertNil(org.message) + XCTAssertEqual(org.organization.id, "o1") + + await transport.enqueue(.json(#"{"folder":{"id":"f1","name":"Docs"}}"#)) + let folder = try await client.send(Documents.folder(id: "f1")) + XCTAssertNil(folder.message) + XCTAssertEqual(folder.folder.id, "f1") + + await transport.enqueue(.json(#"{"data":{"id":"r1","rowData":{}}}"#)) + let row = try await client.send(Lists.row(listId: "l1", rowId: "r1")) + XCTAssertNil(row.message) + XCTAssertEqual(row.data.id, "r1") + } + + // MARK: - Request bodies + + /// V3's third defect: the list-row wire field is `data`. A `rowData` body is + /// rejected `400 "Data is required"` even once the verb is correct. + func test_givenRowRequests_whenEncoded_thenUseDataNotRowDataOnTheWire() throws { + let created = try JSONEncoder().encode(CreateListRowRequest(rowData: ["Title": .string("New")])) + let createdKeys = try XCTUnwrap( + JSONSerialization.jsonObject(with: created) as? [String: Any] + ).keys + XCTAssertEqual(Set(createdKeys), ["data"]) + + let updated = try JSONEncoder().encode(UpdateListRowRequest(rowData: ["Title": .string("New")])) + let updatedKeys = try XCTUnwrap( + JSONSerialization.jsonObject(with: updated) as? [String: Any] + ).keys + XCTAssertEqual(Set(updatedKeys), ["data"]) + } + + /// V1: the reschedule body cannot express a content edit, because the live + /// route silently discards `content` when it accompanies `scheduledAt`. + func test_givenRescheduleRequest_whenEncoded_thenCarriesScheduledAtAlone() throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(RescheduleMessageRequest(scheduledAt: Date(timeIntervalSince1970: 1_800_000_000))) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(Set(object.keys), ["scheduledAt"]) + } + + // MARK: - Upstream failure + + /// The shape of the failure each defect produced in production: a 405 with + /// the server's `Allow` header. Kept so the mapping stays exercised. + func test_given405FromWrongVerb_whenSent_thenSurfacesAsAPIError() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json( + #"{"error":"Method Not Allowed"}"#, + status: 405, + headers: ["Allow": "OPTIONS, PATCH"] + )) + + do { + _ = try await client.send(User.update(UpdateUserRequest(displayName: "Ada"))) + XCTFail("Expected the 405 to throw") + } catch is APIError { + // Expected — the client must not treat a 405 as a success. + } + } + + /// V6 could not be exercised to a 2xx (the test account has no followers), + /// so the observed live response is what is locked: the handler is reached + /// and answers a business-level 404, not a 405. + func test_givenNoSuchFollower_whenRemoveSent_thenSurfacesNotFoundNot405() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Follower relationship not found","code":"not_found"}"#, status: 404)) + + do { + _ = try await client.send(Follow.remove(userId: "u404")) + XCTFail("Expected notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Follower relationship not found")) + } + + let received = await transport.received + XCTAssertEqual(received[0].httpMethod, "DELETE") + } + + // MARK: - Boundary + + /// Boundary: an empty patch body still encodes as a valid object on the + /// corrected verb rather than tripping the encoder. + func test_givenEmptyBodies_whenBuiltOnCorrectedVerbs_thenStillEncode() throws { + let user = User.update(UpdateUserRequest()) + XCTAssertEqual(user.method, .patch) + + let row = Lists.updateRow(listId: "l1", rowId: "r1", UpdateListRowRequest(rowData: [:])) + XCTAssertEqual(row.method, .put) + + let org = Organizations.update(id: "o1", UpdateOrganizationRequest()) + XCTAssertEqual(org.method, .put) + } + + /// Boundary: a repo slug already contains a slash, so the flat GitHub paths + /// must interpolate `owner/repo` without double-escaping it. + func test_givenOwnerRepoSlug_whenFlatGitHubPathsBuilt_thenSlugIsNotEscaped() { + let update = GitHub.updateIssue( + repo: "CompositeCode/interlinedlist", + number: 7, + UpdateGitHubIssueRequest(assignees: ["octocat"]) + ) + XCTAssertEqual(update.path, "/api/github/issues/CompositeCode/interlinedlist/7") + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift index f68005a..b6d9c40 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift @@ -420,14 +420,29 @@ final class MessagesEndpointTests: XCTestCase { } } - // MARK: - update / delete + // MARK: - reschedule / delete - func test_givenEdit_whenUpdateBuilt_thenPutsToMessagePath() throws { - let request = Messages.update(id: "m1", CreateMessageRequest(content: "edited")) - XCTAssertEqual(request.method, .put) + // `Messages.update` is gone: `PUT /api/messages/[id]` is 405 live and the + // `PATCH` that replaced it only moves a scheduled post's send time — it is + // not a message editor (verified 2026-09-06 — work-consolidation.md §1c · V1). + func test_givenNewDate_whenRescheduleBuilt_thenPatchesMessagePath() throws { + let when = Date(timeIntervalSince1970: 1_800_000_000) + let request = Messages.reschedule(id: "m1", RescheduleMessageRequest(scheduledAt: when)) + XCTAssertEqual(request.method, .patch) XCTAssertEqual(request.path, "/api/messages/m1") XCTAssertEqual(request.auth, .bearer) - XCTAssertEqual(try encodedBody(request)["content"] as? String, "edited") + } + + // Boundary: the body carries `scheduledAt` and nothing else — sending + // `content` alongside it is silently ignored by the server, so the request + // type must not be able to express one. + func test_givenReschedule_whenBodyEncoded_thenCarriesOnlyScheduledAt() throws { + let request = Messages.reschedule( + id: "m1", + RescheduleMessageRequest(scheduledAt: Date(timeIntervalSince1970: 1_800_000_000)) + ) + let body = try encodedBody(request) + XCTAssertEqual(Set(body.keys), ["scheduledAt"]) } func test_givenMessageId_whenDeleteBuilt_thenDeletesMessagePath() { diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift index d88a9d4..38c891b 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/OrganizationsEndpointTests.swift @@ -28,7 +28,8 @@ final class OrganizationsEndpointTests: XCTestCase { XCTAssertEqual(Organizations.create(CreateOrganizationRequest(name: "Acme", description: "d", isPublic: true)).method, .post) XCTAssertEqual(Organizations.get(id: "o1").path, "/api/organizations/o1") - XCTAssertEqual(Organizations.update(id: "o1", UpdateOrganizationRequest(name: "x")).method, .patch) + // PUT, not PATCH — PATCH is 405 live (work-consolidation.md §1c · V4). + XCTAssertEqual(Organizations.update(id: "o1", UpdateOrganizationRequest(name: "x")).method, .put) XCTAssertEqual(Organizations.members(id: "o1").path, "/api/organizations/o1/members") XCTAssertEqual(Organizations.members(id: "o1").paginationKey, "members") diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/SmallGapEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/SmallGapEndpointTests.swift new file mode 100644 index 0000000..efc6961 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/SmallGapEndpointTests.swift @@ -0,0 +1,183 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the **G27 small, self-contained gaps** — four one-off routes +/// the client did not reach, all probed live on 2026-09-06 before being built. +/// +/// | Route | Live `Allow` | Notes | +/// | -------------------------------------- | -------------------------------------------- | ---------------------------------- | +/// | `DELETE /api/notifications/{id}` | `DELETE, OPTIONS` | 204, no body | +/// | `POST /api/messages/{id}/reply-counts` | `OPTIONS, POST` | `{replyCounts, repliesCheckedAt}` | +/// | `GET /api/user/engagement` | `GET, HEAD, OPTIONS` | **session-only** — 401 on Bearer | +/// | `PUT /api/documents/{id}` | `DELETE, GET, HEAD, OPTIONS, PATCH, PUT` | full replace beside `PATCH` | +final class SmallGapEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + sessionTransport: StubHTTPDataTransport = StubHTTPDataTransport() + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: sessionTransport, + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - Builder shapes + + func test_givenSmallGapBuilders_whenConstructed_thenUseTheLiveVerbPathAndAuth() { + let notification = Notifications.delete(id: "n1") + XCTAssertEqual(notification.method, .delete) + XCTAssertEqual(notification.path, "/api/notifications/n1") + XCTAssertEqual(notification.auth, .bearer) + + let replyCounts = Messages.refreshReplyCounts(id: "m1") + XCTAssertEqual(replyCounts.method, .post) + XCTAssertEqual(replyCounts.path, "/api/messages/m1/reply-counts") + XCTAssertEqual(replyCounts.auth, .bearer) + + let replace = Documents.replace(id: "d1", UpdateDocumentRequest(title: "T")) + XCTAssertEqual(replace.method, .put) + XCTAssertEqual(replace.path, "/api/documents/d1") + XCTAssertEqual(replace.auth, .bearer) + } + + /// Engagement is the one that is **not** `.bearer`. A regression to bearer + /// would 401 against production, which is exactly how it was mis-recorded + /// as "may be session-only, confirm before building". + func test_givenEngagementBuilder_whenConstructed_thenUsesSessionAuth() { + let request = User.engagement() + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, "/api/user/engagement") + XCTAssertEqual(request.auth, .session, "engagement 401s under Bearer — it must use the cookie session") + } + + // MARK: - Happy path + + func test_givenReplyCountEnvelope_whenRefreshSent_thenDecodesPerPlatformRows() async throws { + let (client, transport) = makeClient() + // The real 2026-09-06 body. + await transport.enqueue(.json(#""" + {"replyCounts":[ + {"platform":"mastodon","count":0,"status":"success","checkedAt":"2026-09-06T06:59:29.004Z"}, + {"platform":"bluesky","count":3,"status":"success","checkedAt":"2026-09-06T06:59:29.005Z"}, + {"platform":"twitter","status":"unsupported","checkedAt":"2026-09-06T06:59:29.132Z"}], + "repliesCheckedAt":"2026-09-06T06:59:28.751Z"} + """#)) + + let response = try await client.send(Messages.refreshReplyCounts(id: "m1")) + + XCTAssertEqual(response.replyCounts.map(\.platform), ["mastodon", "bluesky", "twitter"]) + XCTAssertEqual(response.replyCounts[1].count, 3) + XCTAssertNotNil(response.repliesCheckedAt) + } + + /// Boundary: an unsupported platform reports no `count` at all. It must + /// decode as `nil`, never as a misleading `0`. + func test_givenUnsupportedPlatform_whenRefreshSent_thenCountIsNilNotZero() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"replyCounts":[{"platform":"twitter","status":"unsupported","checkedAt":"2026-09-06T06:59:29.132Z"}], + "repliesCheckedAt":null} + """#)) + + let response = try await client.send(Messages.refreshReplyCounts(id: "m1")) + + XCTAssertNil(response.replyCounts.first?.count) + XCTAssertEqual(response.replyCounts.first?.status, "unsupported") + XCTAssertNil(response.repliesCheckedAt) + } + + func test_givenEngagementEnvelope_whenSent_thenDecodesTotalsAndRecent() async throws { + let session = StubHTTPDataTransport() + await session.enqueue(.json(#""" + {"totalDigs":24,"totalPushes":6, + "recent":[{"id":"n1","title":"Pushed — Adron Hall (@adron)","body":"Your message: …", + "type":"push","sourceMessageId":"m1","createdAt":"2026-09-01T00:00:00.000Z", + "routePath":"/messages/m1"}]} + """#)) + let (client, _) = makeClient(sessionTransport: session) + + let response = try await client.send(User.engagement()) + + XCTAssertEqual(response.totalDigs, 24) + XCTAssertEqual(response.totalPushes, 6) + XCTAssertEqual(response.recent.first?.sourceMessageId, "m1") + } + + func test_givenDocumentEnvelope_whenReplaceSent_thenDecodesUnderDocumentKey() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"message":"Document updated successfully", + "document":{"id":"d1","title":"Replaced","content":"new body"}} + """#)) + + let response = try await client.send( + Documents.replace(id: "d1", UpdateDocumentRequest(title: "Replaced", content: "new body")) + ) + + XCTAssertEqual(response.document.title, "Replaced") + let received = await transport.received + XCTAssertEqual(received[0].httpMethod, "PUT") + } + + // MARK: - Boundary + + /// The notification delete answers 204 with no body, so it must go through + /// `sendVoid` without a decode step. + func test_givenNoContent_whenNotificationDeleteSent_thenSucceeds() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.empty(status: 204)) + + try await client.sendVoid(Notifications.delete(id: "n1")) + + let received = await transport.received + XCTAssertEqual(received[0].httpMethod, "DELETE") + XCTAssertEqual(received[0].url?.path, "/api/notifications/n1") + } + + /// Boundary: zero totals and an empty feed are a legitimate fresh account, + /// not a decode failure. + func test_givenEmptyEngagement_whenSent_thenDecodesZeroTotals() async throws { + let session = StubHTTPDataTransport() + await session.enqueue(.json(#"{"totalDigs":0,"totalPushes":0,"recent":[]}"#)) + let (client, _) = makeClient(sessionTransport: session) + + let response = try await client.send(User.engagement()) + + XCTAssertEqual(response.totalDigs, 0) + XCTAssertTrue(response.recent.isEmpty) + } + + // MARK: - Upstream failure + + func test_givenMissingNotification_whenDeleteSent_thenThrowsNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Not found"}"#, status: 404)) + + do { + try await client.sendVoid(Notifications.delete(id: "gone")) + XCTFail("Expected notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Not found")) + } + } + + /// The failure the live route produces when the bearer token is used + /// instead of the session — the reason `engagement()` is `.session`. + func test_givenUnauthorized_whenEngagementSent_thenThrowsUnauthorized() async throws { + let session = StubHTTPDataTransport() + await session.enqueue(.json(#"{"error":"Unauthorized","code":"unauthorized"}"#, status: 401)) + let (client, _) = makeClient(sessionTransport: session) + + do { + _ = try await client.send(User.engagement()) + XCTFail("Expected unauthorized") + } catch let error as APIError { + XCTAssertEqual(error, .unauthorized(serverMessage: "Unauthorized")) + } + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index f22b5e0..9e5d701 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -173,10 +173,12 @@ final class UserEndpointTests: XCTestCase { // MARK: - update - func test_givenProfilePatch_whenUpdateBuilt_thenPostsOnlySetFields() throws { + func test_givenProfilePatch_whenUpdateBuilt_thenPatchesOnlySetFields() throws { // Happy path + boundary: nil fields omitted. + // PATCH, not POST — POST is 405 live, which is why Settings ▸ Preferences + // never saved (work-consolidation.md §1c · V2). let request = User.update(UpdateUserRequest(displayName: "New Name", bio: nil)) - XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.method, .patch) XCTAssertEqual(request.path, "/api/user/update") XCTAssertEqual(request.auth, .bearer) let body = try encodedBody(request) @@ -198,7 +200,7 @@ final class UserEndpointTests: XCTestCase { let response = try await client.send(User.update(UpdateUserRequest(displayName: "Ada"))) XCTAssertEqual(response.user.username, "ada") let received = await transport.received - XCTAssertEqual(received[0].httpMethod, "POST") + XCTAssertEqual(received[0].httpMethod, "PATCH") } func test_givenInvalidPatch_whenUpdateSent_thenThrowsBadRequest() async throws {