From 7f79eb0b9d76596a897b2a41168e9e3105ad770f Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 10:08:04 -0700 Subject: [PATCH] feat(documents): rename a document folder in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a folder previously meant deleting and recreating it, which took the folder's documents and subfolders with it. Closes #55. - Add `APIClient.updateDocumentFolder(id:name:parentId:)` hitting `PUT /api/documents/folders/{id}` via `putCamel` — the route reads plain camelCase keys, so the snake_case `put` would send `parent_id` and the server would silently ignore the move. Lives in a new `APIClient+Documents.swift` extension per CLAUDE.md rather than growing `APIClient.swift`. - Model `parentId` as `DocumentFolderParentUpdate` (`.unchanged` / `.root` / `.folder`) with a hand-written `encode(to:)`. The route validates and moves whenever the key is *present*, and Swift's synthesized `Encodable` drops a `nil` optional — so a rename must omit the key entirely, while a move to root needs an explicit `null`. One signature can't express that with a plain `String?`. - Wire Rename into the folder row's context menu and swipe actions in both `DocumentsView` and its nested `DocumentFolderView`, as an inline text field swapped in for the row. The write is optimistic and rolls the row back to the untouched original when the server refuses it. - Surface the server's own copy on refusal: the `400` circular-move rejection and the `409` duplicate-name conflict both arrive as `APIError.server(_:)` with text that reads well as-is. - Mirror the result into `AppDataStore.updateDocumentFolder(_:)` so the rename survives a cold start. Deliberately enqueues no sync operation, unlike its `createDocumentFolderOffline`/`deleteDocumentFolderOffline` neighbours: `POST /api/documents/sync` answers a folder update with `unsupported_operation` ("Folders support only `create` and `delete`"), so an outbox op would be rejected on every push and the rename would never persist. - 13 request-shape tests covering the verb and path, the omitted vs. explicit `parentId`, camelCase keys, the absence of a `folderId` query (folder routes are path-scoped), and the 400/409/401/no-data paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P1SLk6x1krwZ5FgPWGTHR5 --- InterlinedList.xcodeproj/project.pbxproj | 4 + InterlinedList/Models/Document.swift | 8 + .../Services/APIClient+Documents.swift | 62 +++++ InterlinedList/Services/AppDataStore.swift | 14 ++ InterlinedList/Views/DocumentsView.swift | 234 +++++++++++++++--- .../APIClientDocumentsTests.swift | 117 +++++++++ 6 files changed, 407 insertions(+), 32 deletions(-) create mode 100644 InterlinedList/Services/APIClient+Documents.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f6d3089..5277019 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */; }; + 90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F84433332486544DA15C7BA /* APIClient+Documents.swift */; }; 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; }; 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */; }; 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; }; @@ -232,6 +233,7 @@ 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTagsTests.swift; sourceTree = ""; }; 164718CC67967AC147E30465 /* CrossPostLinksView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossPostLinksView.swift; sourceTree = ""; }; 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MutedUsersView.swift; sourceTree = ""; }; + 1F84433332486544DA15C7BA /* APIClient+Documents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Documents.swift"; sourceTree = ""; }; 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; @@ -540,6 +542,7 @@ 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, + 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, ); path = Services; sourceTree = ""; @@ -932,6 +935,7 @@ FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */, 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */, E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, + 90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/Document.swift b/InterlinedList/Models/Document.swift index 6248026..1a111ce 100644 --- a/InterlinedList/Models/Document.swift +++ b/InterlinedList/Models/Document.swift @@ -55,6 +55,14 @@ struct DocumentFolder: Codable, Identifiable { } } +extension DocumentFolder { + /// A copy under a new name, for showing a rename before the server confirms it. + func renamed(to newName: String) -> DocumentFolder { + DocumentFolder(id: id, name: newName, parentId: parentId, + updatedAt: updatedAt, deletedAt: deletedAt) + } +} + /// A starter document a subscriber can copy into a new document. `id` is the /// source document's id (`templateDocumentId` for `POST /api/documents/from-template`). struct DocumentTemplate: Codable, Identifiable { diff --git a/InterlinedList/Services/APIClient+Documents.swift b/InterlinedList/Services/APIClient+Documents.swift new file mode 100644 index 0000000..17ed39f --- /dev/null +++ b/InterlinedList/Services/APIClient+Documents.swift @@ -0,0 +1,62 @@ +import Foundation + +/// How a folder update should treat `parentId`. +/// +/// `PUT /api/documents/folders/{id}` only validates and applies a move when the +/// `parentId` key is *present* in the body, and Swift's synthesized `Encodable` +/// drops a `nil` optional rather than writing `null`. Without this distinction a +/// rename would be indistinguishable from a move to root. +enum DocumentFolderParentUpdate: Equatable { + /// Leave the folder where it is — omit `parentId` entirely. + case unchanged + /// Move to the top level — send an explicit `null`. + case root + /// Move under another folder. + case folder(String) +} + +extension APIClient { + /// Renames and/or moves a document folder (`PUT /api/documents/folders/{id}`). + /// + /// The route reads plain camelCase keys (`name`, `parentId`), so this goes + /// through `putCamel` — the snake_case `put` would send `parent_id`, which the + /// server ignores, silently dropping the move. + /// + /// A move that would make the folder its own ancestor is rejected server-side + /// with a `400` whose body carries readable text, which `checkResponse` surfaces + /// as `APIError.server(_:)`. A name that collides with a sibling comes back the + /// same way with a `409`. + func updateDocumentFolder(id: String, + name: String? = nil, + parentId: DocumentFolderParentUpdate = .unchanged) async throws -> DocumentFolder { + struct Response: Decodable { let folder: DocumentFolder? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: Response = try await putCamel( + "/api/documents/folders/\(encoded)", + body: UpdateDocumentFolderBody(name: name, parentId: parentId)) + guard let folder = response.folder else { throw APIError.noData } + return folder + } +} + +/// Encodes only the fields the caller actually wants changed, so a rename never +/// re-sends (and re-validates) the folder's parent. +private struct UpdateDocumentFolderBody: Encodable { + let name: String? + let parentId: DocumentFolderParentUpdate + + private enum CodingKeys: String, CodingKey { case name, parentId } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(name, forKey: .name) + switch parentId { + case .unchanged: + break + case .root: + try container.encodeNil(forKey: .parentId) + case .folder(let id): + try container.encode(id, forKey: .parentId) + } + } +} diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 6c67401..fc0bdf2 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -506,6 +506,20 @@ final class AppDataStore: ObservableObject { } func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; persistDocs() } + /// Mirrors a renamed/moved folder into the local tree and persists it, so the + /// change survives a cold start the way a create or delete does. + /// + /// Unlike `createDocumentFolderOffline` / `deleteDocumentFolderOffline` this + /// deliberately enqueues **no** sync operation: `POST /api/documents/sync` + /// answers a folder update with `unsupported_operation` ("Folders support only + /// `create` and `delete`"), so the change reaches the server through + /// `PUT /api/documents/folders/{id}` and this only mirrors the result. + func updateDocumentFolder(_ folder: DocumentFolder) { + guard let idx = documentFolders.firstIndex(where: { $0.id == folder.id }) else { return } + documentFolders[idx] = folder + persistDocs() + } + private func persistDocs() { if offlineDocSyncEnabled { saveDocsSyncCache() diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index 0604e28..6068e1f 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -17,6 +17,10 @@ struct DocumentsView: View { @State private var createdDocument: Document? @State private var folderToDelete: DocumentFolder? @State private var showDeleteFolderConfirm = false + /// The folder whose row is currently an inline rename field, if any. + @State private var renamingFolderID: String? + @State private var renameText = "" + @State private var renameError: String? @State private var searchText = "" @State private var searchResults: [Document] = [] @State private var isSearching = false @@ -204,9 +208,35 @@ struct DocumentsView: View { } message: { _ in Text("This also deletes any documents and subfolders inside it.") } + .alert("Couldn't rename folder", isPresented: Binding( + get: { renameError != nil }, + set: { if !$0 { renameError = nil } } + ), presenting: renameError) { _ in + Button("OK", role: .cancel) { renameError = nil } + } message: { message in + Text(message) + } } } + private func beginRename(of folder: DocumentFolder) { + renameText = folder.name + renamingFolderID = folder.id + } + + /// Shows the new name immediately, then writes it back with whatever the + /// server saved — or with the untouched original if the write was refused. + private func commitRename(of folder: DocumentFolder) async { + let trimmed = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + renamingFolderID = nil + guard !trimmed.isEmpty, trimmed != folder.name else { return } + store.updateDocumentFolder(folder.renamed(to: trimmed)) + let result = await confirmFolderRename(folder, to: trimmed) + store.updateDocumentFolder(result.folder) + if result.wasUnauthorized { authState.handleUnauthorized() } + renameError = result.errorMessage + } + private func deleteFolder(_ folder: DocumentFolder) async { if store.offlineDocumentWritesEnabled { store.deleteDocumentFolderOffline(id: folder.id) @@ -347,23 +377,40 @@ struct DocumentsView: View { if !rootFolders.isEmpty { Section("Folders") { ForEach(rootFolders) { folder in - NavigationLink(destination: DocumentFolderView(folder: folder)) { - Label(folder.name, systemImage: "folder") - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - folderToDelete = folder - showDeleteFolderConfirm = true - } label: { - Label("Delete", systemImage: "trash") + if renamingFolderID == folder.id { + FolderRenameField(originalName: folder.name, text: $renameText, + onCommit: { Task { await commitRename(of: folder) } }, + onCancel: { renamingFolderID = nil }) + } else { + NavigationLink(destination: DocumentFolderView(folder: folder)) { + Label(folder.name, systemImage: "folder") } - } - .contextMenu { - Button(role: .destructive) { - folderToDelete = folder - showDeleteFolderConfirm = true - } label: { - Label("Delete Folder", systemImage: "trash") + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + folderToDelete = folder + showDeleteFolderConfirm = true + } label: { + Label("Delete", systemImage: "trash") + } + Button { + beginRename(of: folder) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(Color(ILColor.primary)) + } + .contextMenu { + Button { + beginRename(of: folder) + } label: { + Label("Rename", systemImage: "pencil") + } + Button(role: .destructive) { + folderToDelete = folder + showDeleteFolderConfirm = true + } label: { + Label("Delete Folder", systemImage: "trash") + } } } } @@ -415,6 +462,10 @@ private struct DocumentFolderView: View { @State private var createdFromTemplate: Document? @State private var folderToDelete: DocumentFolder? @State private var showDeleteFolderConfirm = false + /// The subfolder whose row is currently an inline rename field, if any. + @State private var renamingFolderID: String? + @State private var renameText = "" + @State private var renameError: String? var body: some View { Group { @@ -497,6 +548,14 @@ private struct DocumentFolderView: View { } message: { _ in Text("This also deletes any documents and subfolders inside it.") } + .alert("Couldn't rename folder", isPresented: Binding( + get: { renameError != nil }, + set: { if !$0 { renameError = nil } } + ), presenting: renameError) { _ in + Button("OK", role: .cancel) { renameError = nil } + } message: { message in + Text(message) + } } private var folderList: some View { @@ -504,23 +563,40 @@ private struct DocumentFolderView: View { if !subfolders.isEmpty { Section("Folders") { ForEach(subfolders) { sub in - NavigationLink(destination: DocumentFolderView(folder: sub)) { - Label(sub.name, systemImage: "folder") - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - folderToDelete = sub - showDeleteFolderConfirm = true - } label: { - Label("Delete", systemImage: "trash") + if renamingFolderID == sub.id { + FolderRenameField(originalName: sub.name, text: $renameText, + onCommit: { Task { await commitRename(of: sub) } }, + onCancel: { renamingFolderID = nil }) + } else { + NavigationLink(destination: DocumentFolderView(folder: sub)) { + Label(sub.name, systemImage: "folder") } - } - .contextMenu { - Button(role: .destructive) { - folderToDelete = sub - showDeleteFolderConfirm = true - } label: { - Label("Delete Folder", systemImage: "trash") + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + folderToDelete = sub + showDeleteFolderConfirm = true + } label: { + Label("Delete", systemImage: "trash") + } + Button { + beginRename(of: sub) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(Color(ILColor.primary)) + } + .contextMenu { + Button { + beginRename(of: sub) + } label: { + Label("Rename", systemImage: "pencil") + } + Button(role: .destructive) { + folderToDelete = sub + showDeleteFolderConfirm = true + } label: { + Label("Delete Folder", systemImage: "trash") + } } } } @@ -583,6 +659,31 @@ private struct DocumentFolderView: View { } } + private func beginRename(of sub: DocumentFolder) { + renameText = sub.name + renamingFolderID = sub.id + } + + /// Mirrors `DocumentsView.commitRename`, and additionally keeps this view's + /// locally-held `subfolders` in step with the store. + private func commitRename(of sub: DocumentFolder) async { + let trimmed = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + renamingFolderID = nil + guard !trimmed.isEmpty, trimmed != sub.name else { return } + applyRenamed(sub.renamed(to: trimmed)) + let result = await confirmFolderRename(sub, to: trimmed) + applyRenamed(result.folder) + if result.wasUnauthorized { authState.handleUnauthorized() } + renameError = result.errorMessage + } + + private func applyRenamed(_ updated: DocumentFolder) { + store.updateDocumentFolder(updated) + if let idx = subfolders.firstIndex(where: { $0.id == updated.id }) { + subfolders[idx] = updated + } + } + private func deleteFolder(_ sub: DocumentFolder) async { if store.offlineDocumentWritesEnabled { store.deleteDocumentFolderOffline(id: sub.id) @@ -1179,6 +1280,75 @@ private struct TemplatePickerView: View { } } +/// Inline editor swapped in for a folder row while that folder is being renamed. +private struct FolderRenameField: View { + let originalName: String + @Binding var text: String + let onCommit: () -> Void + let onCancel: () -> Void + @FocusState private var focused: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + TextField("Folder name", text: $text) + .autocorrectionDisabled() + .submitLabel(.done) + .focused($focused) + .onSubmit(onCommit) + .accessibilityLabel("New name for folder \(originalName)") + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Cancel renaming \(originalName)") + } + .onAppear { focused = true } + } +} + +/// Outcome of confirming an optimistic folder rename with the server. +private struct FolderRenameResult { + /// What the row should show now: the saved folder, or `original` as a rollback. + let folder: DocumentFolder + /// Readable text when the server refused the write, `nil` when it succeeded. + let errorMessage: String? + /// A 401 is re-validated by the caller rather than shown as an alert. + let wasUnauthorized: Bool +} + +/// Confirms an already-applied optimistic rename. Never throws — the caller +/// always gets a folder value to write back, so the row can't be left showing a +/// name the server never accepted. +@MainActor +private func confirmFolderRename(_ original: DocumentFolder, + to newName: String) async -> FolderRenameResult { + do { + let saved = try await APIClient.shared.updateDocumentFolder(id: original.id, name: newName) + return FolderRenameResult(folder: saved, errorMessage: nil, wasUnauthorized: false) + } catch APIError.status(401) { + return FolderRenameResult(folder: original, errorMessage: nil, wasUnauthorized: true) + } catch { + return FolderRenameResult(folder: original, + errorMessage: folderWriteMessage(for: error), + wasUnauthorized: false) + } +} + +/// Prefers the server's own copy for a refused folder write: the `400` that +/// rejects a circular move and the `409` duplicate-name conflict both arrive as +/// `APIError.server(_:)` carrying text that reads well as-is. +private func folderWriteMessage(for error: Error) -> String { + switch error { + case APIError.server(let message), APIError.conflict(let message), APIError.forbidden(let message): + return message + default: + return "Couldn't update the folder. Please try again." + } +} + private func parseISODate(_ string: String) -> Date? { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift index 5d01d66..f8dc429 100644 --- a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift @@ -7,6 +7,7 @@ final class APIClientDocumentsTests: XCTestCase { private let docJSON = #"{"id":"d1","title":"My Doc"}"# private let folderJSON = #"{"id":"f1","name":"Folder"}"# + private let renamedFolderJSON = #"{"id":"f1","name":"Renamed","parentId":"f2"}"# override func setUp() { super.setUp() @@ -161,4 +162,120 @@ final class APIClientDocumentsTests: XCTestCase { XCTAssertEqual(code, 401) } } + + // MARK: updateDocumentFolder() + + func test_updateDocumentFolder_sendsPutToFolderPath() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + let folder = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/folders/f1") + XCTAssertEqual(folder.name, "Renamed") + } + + func test_updateDocumentFolder_pathScoped_noFolderIdQuery() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(url.contains("folderId="), "folder routes are path-scoped, got: \(url)") + XCTAssertFalse(url.contains("?"), "expected no query, got: \(url)") + } + + func test_updateDocumentFolder_encodesIdIntoPath() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f 1", name: "Renamed") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/folders/f 1") + } + + func test_updateDocumentFolder_bodyUsesCamelCaseName() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + let body = bodyString() + XCTAssertEqual(body, #"{"name":"Renamed"}"#) + } + + /// A rename must not carry `parentId` at all: the route validates (and moves) + /// whenever the key is present, so a stray `null` would silently move the + /// folder to the root. + func test_updateDocumentFolder_rename_omitsParentId() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + XCTAssertFalse(bodyString().contains("parentId")) + XCTAssertFalse(bodyString().contains("parent_id")) + } + + func test_updateDocumentFolder_moveToFolder_sendsCamelCaseParentId() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", parentId: .folder("f2")) + let body = bodyString() + XCTAssertEqual(body, #"{"parentId":"f2"}"#) + XCTAssertFalse(body.contains("parent_id")) + } + + /// Moving to the root needs an explicit `null` — Swift's synthesized + /// `Encodable` would drop a `nil` optional and the move would never happen. + func test_updateDocumentFolder_moveToRoot_sendsExplicitNull() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", parentId: .root) + XCTAssertEqual(bodyString(), #"{"parentId":null}"#) + } + + func test_updateDocumentFolder_renameAndMove_sendsBothKeys() async throws { + session.stub(json: #"{"folder":\#(renamedFolderJSON)}"#) + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed", parentId: .folder("f2")) + let body = bodyString() + XCTAssertTrue(body.contains(#""name":"Renamed""#), body) + XCTAssertTrue(body.contains(#""parentId":"f2""#), body) + } + + func test_updateDocumentFolder_decodesSavedFolder() async throws { + session.stub(json: #"{"message":"Folder updated successfully","folder":\#(renamedFolderJSON)}"#) + let folder = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + XCTAssertEqual(folder.id, "f1") + XCTAssertEqual(folder.name, "Renamed") + XCTAssertEqual(folder.parentId, "f2") + } + + /// The server rejects a circular move with a 400 whose body carries readable + /// text; `checkResponse` surfaces it as `.server` so the view can show it. + func test_updateDocumentFolder_cycle400_throwsServerMessage() async throws { + session.stub(json: #"{"error":"Setting this parent would create a circular reference","code":"bad_request"}"#, + statusCode: 400) + do { + _ = try await sut.updateDocumentFolder(id: "f1", parentId: .folder("f1child")) + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "Setting this parent would create a circular reference") + } + } + + func test_updateDocumentFolder_duplicateName409_throwsServerMessage() async throws { + session.stub(json: #"{"error":"A folder with that name already exists here","code":"conflict"}"#, + statusCode: 409) + do { + _ = try await sut.updateDocumentFolder(id: "f1", name: "Taken") + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "A folder with that name already exists here") + } + } + + func test_updateDocumentFolder_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_updateDocumentFolder_missingFolderInResponse_throwsNoData() async throws { + session.stub(json: #"{"message":"Folder updated successfully"}"#) + do { + _ = try await sut.updateDocumentFolder(id: "f1", name: "Renamed") + XCTFail("Expected throw") + } catch APIError.noData { + } + } }