diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 4d5346c..e1c2f0b 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ A057072E1611947A2415D4D9 /* DocumentCollaboratorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */; }; A1B1C1D1E1F10001 /* InterlinedListApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002 /* InterlinedListApp.swift */; }; A1B1C1D1E1F10003 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004 /* RootView.swift */; }; + 90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F84433332486544DA15C7BA /* APIClient+Documents.swift */; }; A1B1C1D1E1F10005 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10006 /* User.swift */; }; A1B1C1D1E1F10007 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10008 /* Message.swift */; }; A1B1C1D1E1F10009 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F1000A /* KeychainService.swift */; }; @@ -287,6 +288,7 @@ 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMergeTests.swift; sourceTree = ""; }; 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkMetadataTests.swift; sourceTree = ""; }; 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMThreadView.swift; sourceTree = ""; }; + 1F84433332486544DA15C7BA /* APIClient+Documents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Documents.swift"; sourceTree = ""; }; 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = ""; }; 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = ""; }; @@ -534,6 +536,7 @@ B1C1D1E1F1A10002 /* DataCache.swift */, B1C1D1E1F1A10004 /* AppDataStore.swift */, D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, + 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, FC5914D730B4355F02E89D81 /* ILColor.swift */, 8B79D26B822DCA1188DF37E9 /* PushService.swift */, 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */, @@ -870,6 +873,7 @@ B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */, D0A1D0A1D0A10001 /* OAuthCoordinator.swift in Sources */, D0A1D0A1D0A10003 /* ChangeEmailView.swift in Sources */, + 90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */, D0A1D0A1D0A10005 /* ForgotPasswordView.swift in Sources */, D0A1D0A1D0A10007 /* ResetPasswordView.swift in Sources */, D0A1D0A1D0A10009 /* EmailVerificationBanner.swift in Sources */, 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 { + } + } }