diff --git a/App/Composition/ListsEventBus.swift b/App/Composition/ListsEventBus.swift index 7c9e089..4be7f91 100644 --- a/App/Composition/ListsEventBus.swift +++ b/App/Composition/ListsEventBus.swift @@ -52,6 +52,15 @@ enum ListsEvent: Sendable, Equatable { /// A watcher was removed from `listId`. case watcherRemoved(listId: String, userId: String) + /// A list's saved views changed — one was created, renamed, forked, + /// deleted, or made the default (work-consolidation.md G40 / issue #81). + /// + /// Carries the whole post-write collection rather than a single row + /// because the writes are not independent: marking a view as the default + /// clears the previous default, so a per-row event would leave a second + /// window showing two defaults at once. + case savedViewsChanged(listId: String, views: [SavedListView]) + /// A connection was created. The graph view appends. case connectionAdded(ListConnection) diff --git a/App/Features/Lists/ListRowsView.swift b/App/Features/Lists/ListRowsView.swift index 15cc6bc..3e84477 100644 --- a/App/Features/Lists/ListRowsView.swift +++ b/App/Features/Lists/ListRowsView.swift @@ -32,9 +32,49 @@ struct ListRowsView: View { /// (work-consolidation.md G16). @State private var showsCreateFrom = false + /// Drives the saved-views menu (work-consolidation.md G40). Built here + /// rather than by the parent so the control ships with the rows pane it + /// arranges — and so a list opened from the "Shared with me" section gets + /// one too, which is the case the feature exists for. + @State private var savedViewsViewModel: SavedViewsViewModel? + var body: some View { content(viewModel: viewModel) .navigationTitle(list.title) + .task(id: viewModel.listId) { + guard let environment else { return } + let model = SavedViewsViewModel( + lists: environment.lists, + eventBus: environment.listsEventBus, + listId: viewModel.listId + ) + savedViewsViewModel = model + // `load()` applies the caller's `isDefault` view, so the list + // opens in the arrangement that person chose — which on a + // shared list is not the same as the one the owner chose. + await model.load() + await subscribeSavedViews(model: model, bus: environment.listsEventBus) + } + } + + /// Cross-window sync for saved-view writes. `[weak model]` per the project + /// rule: Swift 6 Observation does not guarantee `deinit`-time cancellation, + /// so the subscriber must not keep the view model alive by itself. + private func subscribeSavedViews(model: SavedViewsViewModel, bus: ListsEventBus) async { + Task { [weak model] in + for await event in bus.events() { + guard let model else { return } + model.apply(event: event) + } + } + } + + /// How many lines a table / card cell renders. The one `config` value a + /// saved view stores that has a visible effect in this client: the server + /// normalises `mode` to `records` and drops every column/sort key, so + /// `density` is the whole of "applying a view" today (issue #81). + private var cellLineLimit: Int { + savedViewsViewModel?.appliedDensity == .compact ? 1 : 2 } @ViewBuilder @@ -187,6 +227,13 @@ struct ListRowsView: View { .disabled(selection.isEmpty) .help("Turn the selected rows into a new list or document") + // Saved views sit next to the view-mode picker because both + // change how these rows are arranged — one per session, one saved + // and shareable (work-consolidation.md G40). + if let savedViewsViewModel { + SavedViewsControl(viewModel: savedViewsViewModel, isReadOnly: isReadOnly) + } + Spacer() Picker("View", selection: Binding( @@ -216,10 +263,16 @@ struct ListRowsView: View { VStack(spacing: 0) { Table(viewModel.rows, selection: $selection) { TableColumnForEach(columns) { column in - // Header from `label`, cell lookup by `key` — see `ListColumn`. + // Header from `label`, cell lookup by `key` — see `ListColumn` + // (#50) — at the row height the active saved view asks for + // (G40). Both landed on this line; they compose, and taking + // either alone loses something real: dropping `ListColumn` + // reintroduces the empty-cell bug for a column whose key + // differs from its label, and dropping `cellLineLimit` + // silently ignores the view's density. TableColumn(column.label) { (row: ListRow) in Text(row.fields[column.key]?.displayText ?? "") - .lineLimit(2) + .lineLimit(cellLineLimit) } } } @@ -405,7 +458,7 @@ struct ListRowsView: View { .foregroundStyle(.secondary) Text(row.fields[column.key]?.displayText ?? "") .font(.ilBody()) - .lineLimit(2) + .lineLimit(cellLineLimit) Spacer() } } diff --git a/App/Features/Lists/OwnedListsViewModel.swift b/App/Features/Lists/OwnedListsViewModel.swift index 82a970c..414cce1 100644 --- a/App/Features/Lists/OwnedListsViewModel.swift +++ b/App/Features/Lists/OwnedListsViewModel.swift @@ -221,10 +221,11 @@ final class OwnedListsViewModel { lists_loaded.removeAll { $0.id == id } if selectedListID == id { selectedListID = nil } case .rowCreated, .rowUpdated, .rowDeleted, - .schemaChanged, + .schemaChanged, .savedViewsChanged, .watcherChanged, .watcherRemoved, .connectionAdded, .connectionRemoved: - // Sidebar-level view model only tracks list-level events. + // Sidebar-level view model only tracks list-level events. Saved + // views arrange the rows pane, not the sidebar row. break } } diff --git a/App/Features/Lists/SavedViewsControl.swift b/App/Features/Lists/SavedViewsControl.swift new file mode 100644 index 0000000..42562e2 --- /dev/null +++ b/App/Features/Lists/SavedViewsControl.swift @@ -0,0 +1,373 @@ +// SavedViewsControl +// +// The saved-views affordance on the list rows surface (work-consolidation.md +// G40 / issue #81): a menu that applies a view, plus a manage sheet for +// create / rename / duplicate / delete / make-default. +// +// A menu and a sheet rather than a new screen: saved views arrange the rows +// pane, so the control belongs in the rows pane's own toolbar next to the +// view-mode picker. A separate screen would put the arrangement somewhere the +// user cannot see it take effect. +// +// Shared and personal views are separated into their own menu sections and +// carry distinct glyphs, because the whole point of the feature is that a +// collaborator can tell the *list's* arrangement from their own. "Duplicate" +// sits directly under the shared section — that is the spec's "escape hatch", +// and burying it would leave collaborators stuck with the owner's layout. + +import SwiftUI +import InterlinedDomain + +struct SavedViewsControl: View { + + let viewModel: SavedViewsViewModel + /// `true` when the caller may read the list but not change it (a + /// `watcher`-role share). Shared views belong to the list, so offering to + /// create one on a list you cannot edit would be an affordance that 403s; + /// personal views stay available because they are the caller's own. + var isReadOnly: Bool = false + + @State private var showsManageSheet = false + @State private var showsCreateSheet = false + + var body: some View { + Menu { + menuContent + } label: { + Label(menuTitle, systemImage: "line.3.horizontal.decrease.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Saved views arrange this list — shared views come from the list, personal ones are yours") + .accessibilityLabel("Saved views") + .sheet(isPresented: $showsCreateSheet) { + NewSavedViewSheet(viewModel: viewModel, allowsSharedScope: !isReadOnly) + } + .sheet(isPresented: $showsManageSheet) { + ManageSavedViewsSheet(viewModel: viewModel, allowsSharedScope: !isReadOnly) + } + } + + /// The applied view's name, or a neutral label. Not "Default" — a list + /// with no applied view is not showing a default, it is showing the list. + private var menuTitle: String { + viewModel.selectedView?.name ?? "All Records" + } + + @ViewBuilder + private var menuContent: some View { + Button { + viewModel.select(viewID: nil) + } label: { + Label("All Records", systemImage: viewModel.selectedViewID == nil ? "checkmark" : "list.bullet") + } + + if !viewModel.sharedViews.isEmpty { + Section("Shared with the list") { + ForEach(viewModel.sharedViews) { view in + viewButton(view, systemImage: "person.2") + } + } + } + + if !viewModel.personalViews.isEmpty { + Section("My views") { + ForEach(viewModel.personalViews) { view in + viewButton(view, systemImage: "person") + } + } + } + + Divider() + + Button("Save Current Arrangement\u{2026}") { + showsCreateSheet = true + } + + // The escape hatch, surfaced at the top level rather than only inside + // the manage sheet: taking a copy of someone's shared view is the + // action a collaborator reaches for most. + if let selected = viewModel.selectedView { + Button("Duplicate \u{201C}\(selected.name)\u{201D}") { + Task { await viewModel.fork(viewID: selected.id, name: "\(selected.name) copy") } + } + } + + Button("Manage Views\u{2026}") { + showsManageSheet = true + } + } + + @ViewBuilder + private func viewButton(_ view: SavedListView, systemImage: String) -> some View { + Button { + viewModel.select(viewID: view.id) + } label: { + Label( + view.isDefault ? "\(view.name) (default)" : view.name, + systemImage: viewModel.selectedViewID == view.id ? "checkmark" : systemImage + ) + } + } +} + +// MARK: - Create sheet + +/// Names a new view and picks its scope. Scope is a deliberate, explicit +/// choice rather than a default: `shared` publishes the arrangement to everyone +/// with access to the list, and that is not something to fall into. +private struct NewSavedViewSheet: View { + + let viewModel: SavedViewsViewModel + let allowsSharedScope: Bool + + @Environment(\.dismiss) private var dismiss + @State private var name: String = "" + @State private var scope: SavedListViewScope = .personal + @State private var makeDefault: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Save Current Arrangement") + .font(.ilSubtitle()) + + TextField("View name", text: $name) + .textFieldStyle(.roundedBorder) + + if allowsSharedScope { + Picker("Visible to", selection: $scope) { + Text("Only me").tag(SavedListViewScope.personal) + Text("Everyone on this list").tag(SavedListViewScope.shared) + } + .pickerStyle(.radioGroup) + } else { + // Read-only share: a shared view belongs to the list, so + // offering it here would be an affordance the server refuses. + Label("Saved to your own views", systemImage: "person") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + + Toggle("Open this list with this view", isOn: $makeDefault) + .help("Defaults are per person — yours does not change what collaborators see") + + if let message = viewModel.validationMessage { + Text(message) + .font(.ilMono(10)) + .foregroundStyle(.red) + } + + HStack { + Button("Cancel", role: .cancel) { dismiss() } + Spacer() + Button("Save") { + Task { + await viewModel.create( + name: name, + scope: allowsSharedScope ? scope : .personal, + makeDefault: makeDefault + ) + if viewModel.validationMessage == nil, viewModel.error == nil { + dismiss() + } + } + } + .buttonStyle(.borderedProminent) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(16) + .frame(minWidth: 380) + } +} + +// MARK: - Manage sheet + +/// Lists every view grouped by scope and offers rename / duplicate / delete / +/// make-default per row. +private struct ManageSavedViewsSheet: View { + + let viewModel: SavedViewsViewModel + let allowsSharedScope: Bool + + @Environment(\.dismiss) private var dismiss + @State private var renamingViewID: String? + @State private var renameText: String = "" + @State private var deletePendingViewID: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Saved Views") + .font(.ilSubtitle()) + Spacer() + if viewModel.isLoading { + ProgressView().controlSize(.small) + } + } + .padding(16) + + Divider() + + if viewModel.views.isEmpty { + emptyState + } else { + List { + if !viewModel.sharedViews.isEmpty { + Section("Shared with the list") { + ForEach(viewModel.sharedViews) { row($0) } + } + } + if !viewModel.personalViews.isEmpty { + Section("My views") { + ForEach(viewModel.personalViews) { row($0) } + } + } + } + .listStyle(.inset) + } + + if let error = viewModel.error { + Divider() + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .font(.ilMono(10)) + .padding(8) + } + if let message = viewModel.validationMessage { + Divider() + Text(message) + .font(.ilMono(10)) + .foregroundStyle(.red) + .padding(8) + } + + Divider() + HStack { + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + } + .padding(16) + } + .frame(minWidth: 460, minHeight: 320) + .confirmationDialog( + "Delete this view?", + isPresented: Binding( + get: { deletePendingViewID != nil }, + set: { if !$0 { deletePendingViewID = nil } } + ) + ) { + Button("Delete", role: .destructive) { + if let id = deletePendingViewID { + Task { await viewModel.delete(viewID: id) } + } + deletePendingViewID = nil + } + Button("Cancel", role: .cancel) { deletePendingViewID = nil } + } message: { + Text("Deleting a shared view removes it for everyone on this list.") + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.ilDisplay(32)) + .foregroundStyle(.secondary) + Text("No saved views") + .font(.ilSubtitle()) + Text("Save the current arrangement to come back to it later.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + @ViewBuilder + private func row(_ view: SavedListView) -> some View { + HStack(spacing: 8) { + Image(systemName: view.isShared ? "person.2" : "person") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + if renamingViewID == view.id { + TextField("View name", text: $renameText) + .textFieldStyle(.roundedBorder) + .onSubmit { commitRename(view) } + Button("Save") { commitRename(view) } + .disabled(renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel", role: .cancel) { renamingViewID = nil } + } else { + VStack(alignment: .leading, spacing: 1) { + Text(view.name) + .lineLimit(1) + HStack(spacing: 6) { + Text(view.scope.displayName) + Text("· \(view.config.density.displayName)") + if view.isDefault { + Text("· opens by default") + } + } + .font(.ilMono(9)) + .foregroundStyle(.secondary) + } + Spacer() + + if viewModel.pendingOperations.contains(view.id) { + ProgressView().controlSize(.small) + } + + // Duplicate is offered on every row, shared or personal: the + // live API forks a personal view too, and "make me a copy I can + // change" is the same intent either way. + Button { + Task { await viewModel.fork(viewID: view.id, name: "\(view.name) copy") } + } label: { + Image(systemName: "plus.square.on.square") + } + .buttonStyle(.borderless) + .help("Duplicate into your own views") + + if !view.isDefault { + Button { + Task { await viewModel.makeDefault(viewID: view.id) } + } label: { + Image(systemName: "star") + } + .buttonStyle(.borderless) + .help("Open this list with this view") + } + + // Renaming and deleting a shared view changes it for everyone, + // so on a read-only share those are hidden rather than shown + // disabled (the project's "never enabled-but-broken" rule). + if !view.isShared || allowsSharedScope { + Button { + renameText = view.name + renamingViewID = view.id + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Rename") + + Button(role: .destructive) { + deletePendingViewID = view.id + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Delete") + } + } + } + .padding(.vertical, 2) + } + + private func commitRename(_ view: SavedListView) { + let name = renameText + renamingViewID = nil + Task { await viewModel.rename(viewID: view.id, to: name) } + } +} diff --git a/App/Features/Lists/SavedViewsViewModel.swift b/App/Features/Lists/SavedViewsViewModel.swift new file mode 100644 index 0000000..df87804 --- /dev/null +++ b/App/Features/Lists/SavedViewsViewModel.swift @@ -0,0 +1,379 @@ +// SavedViewsViewModel +// +// Drives the saved-views control on the owned/shared list rows surface +// (work-consolidation.md G40 / issue #81). Owns the loaded views, which one is +// applied, and the create / rename / delete / fork / make-default writes. +// +// Reads through `ListsServicing` only — per decision 0003 this file consumes +// `InterlinedDomain` and never `InterlinedKit`. +// +// **What "applying a view" can actually do today.** The server normalises +// `config` down to four keys and stores exactly one `mode` (`records`), so the +// only stored value with a visible client effect is `density`. That is what +// `appliedDensity` projects. `filters` and `search` are carried through every +// write untouched rather than dropped, because the web may have written values +// this client cannot yet interpret — see `SavedListViewConfig.filters` for why +// their grammar is unconfirmed. Inventing UI for them would be inventing a +// contract. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class SavedViewsViewModel { + + private let lists: ListsServicing + private let eventBus: ListsEventBus + let listId: String + + // MARK: - Observable state + + /// Every view on this list, in the server's order: the list's shared views + /// plus the caller's personal ones. Never re-sorted — `position` repeats + /// across scope buckets, so sorting on it would interleave the two sets. + private(set) var views: [SavedListView] = [] + + /// The applied view's id, or `nil` for the list's plain unsaved + /// arrangement. Seeded from `isDefault` on the first load. + private(set) var selectedViewID: String? + + private(set) var isLoading: Bool = false + + /// The most recent failure. Cleared at the start of the next write. + private(set) var error: Error? + + /// In-flight write set keyed by view id, so a double-click on Delete or a + /// rapid default flip cannot fire the same write twice. + private(set) var pendingOperations: Set = [] + + /// Set when a write was rejected for a reason the user can fix — today only + /// a blank name. Held separately from `error` so the sheet can render it on + /// the offending field rather than as a banner. + private(set) var validationMessage: String? + + // MARK: - Derived + + var selectedView: SavedListView? { + guard let selectedViewID else { return nil } + return views.first { $0.id == selectedViewID } + } + + /// The list's own views — visible to everyone with access. + var sharedViews: [SavedListView] { views.filter(\.isShared) } + + /// The caller's private views. + var personalViews: [SavedListView] { views.filter { !$0.isShared } } + + /// The density the rows pane should render at. Falls back to the server's + /// own create default when no view is applied, so an unsaved list and a + /// freshly-created view look identical rather than subtly different. + var appliedDensity: SavedListViewDensity { + selectedView?.config.density ?? SavedListViewConfig.serverDefault.density + } + + /// The config a *new* view should start from: whatever is applied now, so + /// "Save current arrangement" means what it says. + var currentConfig: SavedListViewConfig { + selectedView?.config ?? .serverDefault + } + + // MARK: - Init + + init(lists: ListsServicing, eventBus: ListsEventBus, listId: String) { + self.lists = lists + self.eventBus = eventBus + self.listId = listId + } + + // MARK: - Loading + + /// Loads the list's views and applies the caller's default, if they set one. + /// + /// `isDefault` is **per user**, so on a shared list two people legitimately + /// open the same list into different arrangements — honouring it here is + /// the point of the flag, not a nicety. + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let loaded = try await lists.savedViews(of: listId) + views = loaded + selectedViewID = loaded.first(where: \.isDefault)?.id + } catch { + self.error = error + } + } + + // MARK: - Selection + + /// Applies a view, or `nil` for the list's plain arrangement. Local only — + /// picking a view is not a write, so it costs no round-trip and works + /// offline over whatever is already loaded. + func select(viewID: String?) { + guard viewID == nil || views.contains(where: { $0.id == viewID }) else { return } + selectedViewID = viewID + } + + // MARK: - Writes + + /// Creates a view capturing the current arrangement. + /// + /// Blank names are refused here as well as in the service: the sheet's + /// Save button is disabled for one, so reaching this guard means a keyboard + /// path got through, and a silent no-op would look like a hung sheet. + func create(name: String, scope: SavedListViewScope, makeDefault: Bool) async { + guard prepareWrite(named: name) else { return } + do { + let created = try await lists.createSavedView( + listId: listId, + name: name, + scope: scope, + config: currentConfig, + isDefault: makeDefault + ) + // A new default demotes the old one server-side, so rebuild the + // flags locally rather than only appending. + views = applyingDefault(created.isDefault ? created.id : nil, to: views + [created]) + selectedViewID = created.id + publish() + } catch { + surface(error) + } + } + + /// Renames a view. Optimistic: the picker label swaps immediately and is + /// restored if the write fails, because a rename that appears to do nothing + /// for a second reads as a broken control. + func rename(viewID: String, to name: String) async { + guard prepareWrite(named: name), + !pendingOperations.contains(viewID), + let index = views.firstIndex(where: { $0.id == viewID }) else { return } + let snapshot = views + let original = views[index] + views[index] = original.renamed(to: name.trimmingCharacters(in: .whitespacesAndNewlines)) + pendingOperations.insert(viewID) + defer { pendingOperations.remove(viewID) } + do { + // Name only — a partial `config` would REPLACE the stored one and + // silently reset whatever it omitted, so a rename must never carry + // one. + let confirmed = try await lists.updateSavedView( + listId: listId, + viewId: viewID, + name: name, + config: nil, + isDefault: nil + ) + if let currentIndex = views.firstIndex(where: { $0.id == viewID }) { + views[currentIndex] = confirmed + } + publish() + } catch { + views = snapshot + surface(error) + } + } + + /// Marks a view as the caller's default for this list, clearing the + /// previous one locally — the server allows only one. + func makeDefault(viewID: String) async { + guard !pendingOperations.contains(viewID), + views.contains(where: { $0.id == viewID }) else { return } + let snapshot = views + error = nil + validationMessage = nil + views = applyingDefault(viewID, to: views) + pendingOperations.insert(viewID) + defer { pendingOperations.remove(viewID) } + do { + let confirmed = try await lists.updateSavedView( + listId: listId, + viewId: viewID, + name: nil, + config: nil, + isDefault: true + ) + if let currentIndex = views.firstIndex(where: { $0.id == viewID }) { + views[currentIndex] = confirmed + } + publish() + } catch { + views = snapshot + surface(error) + } + } + + /// Changes the applied view's density and saves it. + /// + /// Sends the **complete** config, not just the changed key: `PUT` replaces + /// the config object whole, so omitting `filters` or `search` here would + /// wipe values the web may have set. No-op with nothing applied — there is + /// no view to store the change in. + func setDensity(_ density: SavedListViewDensity) async { + guard let view = selectedView, !pendingOperations.contains(view.id) else { return } + var config = view.config + guard config.density != density else { return } + config.density = density + error = nil + validationMessage = nil + let snapshot = views + pendingOperations.insert(view.id) + defer { pendingOperations.remove(view.id) } + do { + let confirmed = try await lists.updateSavedView( + listId: listId, + viewId: view.id, + name: nil, + config: config, + isDefault: nil + ) + // Take the server's row: an unknown density silently falls back, so + // the stored arrangement and the requested one can differ. + if let index = views.firstIndex(where: { $0.id == view.id }) { + views[index] = confirmed + } + publish() + } catch { + views = snapshot + surface(error) + } + } + + /// Deletes a view. Optimistic remove with snapshot rollback. + func delete(viewID: String) async { + guard !pendingOperations.contains(viewID), + views.contains(where: { $0.id == viewID }) else { return } + let snapshot = views + let previousSelection = selectedViewID + error = nil + validationMessage = nil + views.removeAll { $0.id == viewID } + if selectedViewID == viewID { selectedViewID = nil } + pendingOperations.insert(viewID) + defer { pendingOperations.remove(viewID) } + do { + try await lists.deleteSavedView(listId: listId, viewId: viewID) + publish() + } catch { + views = snapshot + selectedViewID = previousSelection + surface(error) + } + } + + /// Forks a view into a personal copy owned by the caller, and applies it. + /// + /// The escape hatch: a collaborator who wants the owner's shared view but + /// their own tweaks takes a copy instead of editing the list's. A `nil` + /// name lets the server pick one. + func fork(viewID: String, name: String?) async { + guard !pendingOperations.contains(viewID), + views.contains(where: { $0.id == viewID }) else { return } + if let name, name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + validationMessage = ListsError.invalidViewName.localizedDescription + return + } + error = nil + validationMessage = nil + pendingOperations.insert(viewID) + defer { pendingOperations.remove(viewID) } + do { + let forked = try await lists.forkSavedView(listId: listId, viewId: viewID, name: name) + views.append(forked) + selectedViewID = forked.id + publish() + } catch { + surface(error) + } + } + + // MARK: - Event-bus consumption + + /// Applies a `ListsEvent`. Pure local mutation — no refetch. + func apply(event: ListsEvent) { + switch event { + case .savedViewsChanged(let id, let updated) where id == listId: + views = updated + // Keep the applied view if it survived; otherwise fall back to + // whatever is now the default, then to the plain arrangement. + if let selectedViewID, updated.contains(where: { $0.id == selectedViewID }) { return } + selectedViewID = updated.first(where: \.isDefault)?.id + case .listDeleted(let id) where id == listId: + views = [] + selectedViewID = nil + default: + break + } + } + + // MARK: - Internals + + /// Clears the previous error state and rejects a blank name before any + /// round-trip. Returns `false` when the caller should stop. + private func prepareWrite(named name: String) -> Bool { + error = nil + validationMessage = nil + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + validationMessage = ListsError.invalidViewName.localizedDescription + return false + } + return true + } + + /// Rebuilds the collection so exactly `viewID` carries `isDefault`. + private func applyingDefault(_ viewID: String?, to collection: [SavedListView]) -> [SavedListView] { + collection.map { $0.settingDefault($0.id == viewID) } + } + + private func publish() { + eventBus.post(.savedViewsChanged(listId: listId, views: views)) + } + + /// Routes a failure onto the right surface: a domain validation error the + /// user can fix goes to the field, anything else to the error banner. + private func surface(_ error: Error) { + if let listsError = error as? ListsError, listsError == .invalidViewName { + validationMessage = listsError.localizedDescription + } else { + self.error = error + } + } +} + +// MARK: - Local edits + +private extension SavedListView { + /// A copy with a new name, for the optimistic rename. `SavedListView` is + /// immutable by design (every field comes from the server), so local edits + /// are explicit copies rather than in-place mutation. + func renamed(to newName: String) -> SavedListView { + SavedListView( + id: id, + listID: listID, + ownerID: ownerID, + name: newName, + scope: scope, + config: config, + isDefault: isDefault, + position: position + ) + } + + /// A copy with `isDefault` set, for keeping the single-default rule true + /// locally while the write is in flight. + func settingDefault(_ value: Bool) -> SavedListView { + SavedListView( + id: id, + listID: listID, + ownerID: ownerID, + name: name, + scope: scope, + config: config, + isDefault: value, + position: position + ) + } +} diff --git a/App/Features/Lists/WatchedListsViewModel.swift b/App/Features/Lists/WatchedListsViewModel.swift index 37c520b..a831102 100644 --- a/App/Features/Lists/WatchedListsViewModel.swift +++ b/App/Features/Lists/WatchedListsViewModel.swift @@ -154,7 +154,7 @@ final class WatchedListsViewModel { ) } case .listCreated, .rowCreated, .rowUpdated, .rowDeleted, - .schemaChanged, + .schemaChanged, .savedViewsChanged, .watcherChanged, .watcherRemoved, .connectionAdded, .connectionRemoved: break diff --git a/AppTests/SavedViewsViewModelTests.swift b/AppTests/SavedViewsViewModelTests.swift new file mode 100644 index 0000000..90579e0 --- /dev/null +++ b/AppTests/SavedViewsViewModelTests.swift @@ -0,0 +1,552 @@ +// SavedViewsViewModelTests +// +// BDD coverage for the saved-views control's view model +// (work-consolidation.md G40 / issue #81). +// +// Quartet per behavior: happy path, invalid input (which must NOT reach the +// service), upstream failure (which must roll the optimistic state back), and +// an empty/boundary case. Plus the event-bus routing pair — matching id +// mutates, non-matching id is a no-op — per the architecture checklist. +// +// No SwiftUI view is rendered here; `SavedViewsControl` is verified by build +// and by hand. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class SavedViewsViewModelTests: XCTestCase { + + private func makeViewModel( + stub: StubListsService, + eventBus: ListsEventBus = ListsEventBus() + ) -> SavedViewsViewModel { + SavedViewsViewModel(lists: stub, eventBus: eventBus, listId: "L1") + } + + // MARK: - load + + func test_givenSharedAndPersonalViews_whenLoading_thenSplitsThemByScope() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", name: "Team board", scope: .shared), + ListsFixtures.savedView(id: "v-2", name: "Mine", scope: .personal) + ]) + let viewModel = makeViewModel(stub: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.sharedViews.map(\.id), ["v-1"]) + XCTAssertEqual(viewModel.personalViews.map(\.id), ["v-2"]) + XCTAssertNil(viewModel.error) + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .savedViews(listId: "L1")) + } + + func test_givenAViewMarkedDefault_whenLoading_thenAppliesItOnOpen() async { + // The acceptance criterion: `isDefault` is per user, so opening the list + // must land on the arrangement *this* person chose — not the owner's. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", name: "Owner's board", scope: .shared), + ListsFixtures.savedView(id: "v-2", name: "Mine", density: .compact, isDefault: true) + ]) + let viewModel = makeViewModel(stub: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.selectedViewID, "v-2") + XCTAssertEqual(viewModel.appliedDensity, .compact) + } + + func test_givenNoDefaultView_whenLoading_thenAppliesNoneAndFallsBackToTheServerDensity() async { + // Boundary: a list with views but no default opens on the plain list. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", density: .compact)]) + let viewModel = makeViewModel(stub: stub) + + await viewModel.load() + + XCTAssertNil(viewModel.selectedViewID) + XCTAssertEqual(viewModel.appliedDensity, .comfortable) + } + + func test_givenListWithNoViews_whenLoading_thenLeavesEverythingEmpty() async { + // Boundary: the live `{"views":[]}` case. + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + let viewModel = makeViewModel(stub: stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.views.isEmpty) + XCTAssertNil(viewModel.selectedViewID) + XCTAssertNil(viewModel.error) + } + + func test_givenUpstreamFailure_whenLoading_thenSurfacesTheError() async { + let stub = StubListsService() + await stub.enqueueSavedViews(failure: TestError.upstream("denied")) + let viewModel = makeViewModel(stub: stub) + + await viewModel.load() + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.views.isEmpty) + } + + // MARK: - select + + func test_givenLoadedViews_whenSelecting_thenAppliesWithoutCallingTheService() async { + // Applying a view is a local read of data already loaded — it must not + // cost a round-trip, so it keeps working offline. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", density: .compact)]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + viewModel.select(viewID: "v-1") + + XCTAssertEqual(viewModel.selectedViewID, "v-1") + XCTAssertEqual(viewModel.appliedDensity, .compact) + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1, "Selecting must not issue a second call") + } + + func test_givenUnknownViewID_whenSelecting_thenKeepsTheCurrentSelection() async { + // Invalid input: an id that is not loaded must not blank the picker. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", isDefault: true)]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + viewModel.select(viewID: "ghost") + + XCTAssertEqual(viewModel.selectedViewID, "v-1") + } + + // MARK: - create + + func test_givenNameAndScope_whenCreating_thenCallsServiceAndAppliesTheNewView() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + await stub.enqueueCreateSavedView(success: ListsFixtures.savedView(id: "v-9", name: "Reading", scope: .shared)) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.create(name: "Reading", scope: .shared, makeDefault: false) + + XCTAssertEqual(viewModel.views.map(\.id), ["v-9"]) + XCTAssertEqual(viewModel.selectedViewID, "v-9") + XCTAssertNil(viewModel.error) + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .createSavedView(listId: "L1", name: "Reading", scope: .shared, isDefault: false) + ) + } + + func test_givenBlankName_whenCreating_thenReportsValidationAndCallsNoService() async { + // Invalid input. The API accepts "" happily, so nothing downstream + // catches this — an unnamed row in the picker is indistinguishable from + // the next one. + let stub = StubListsService() + let viewModel = makeViewModel(stub: stub) + + await viewModel.create(name: " ", scope: .personal, makeDefault: false) + + XCTAssertNotNil(viewModel.validationMessage) + XCTAssertTrue(viewModel.views.isEmpty) + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty, "A blank name must not reach the service") + } + + func test_givenNewDefaultView_whenCreating_thenDemotesThePreviousDefault() async { + // Boundary on the single-default rule: the server clears the old + // default, so two windows must not end up each showing one. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", isDefault: true)]) + await stub.enqueueCreateSavedView(success: ListsFixtures.savedView(id: "v-2", isDefault: true)) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.create(name: "Newer", scope: .personal, makeDefault: true) + + XCTAssertEqual(viewModel.views.filter(\.isDefault).map(\.id), ["v-2"]) + } + + func test_givenUpstreamFailure_whenCreating_thenSurfacesTheErrorAndAddsNothing() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + await stub.enqueueCreateSavedView(failure: TestError.upstream("bad scope")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.create(name: "Reading", scope: .shared, makeDefault: false) + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.views.isEmpty) + XCTAssertNil(viewModel.selectedViewID) + } + + // MARK: - rename + + func test_givenLoadedView_whenRenaming_thenSwapsTheLabelAndKeepsTheConfigUntouched() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", name: "Old")]) + await stub.enqueueUpdateSavedView(success: ListsFixtures.savedView(id: "v-1", name: "New")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.rename(viewID: "v-1", to: "New") + + XCTAssertEqual(viewModel.views.first?.name, "New") + // A rename must send NO config: `PUT` replaces the stored object whole, + // so a partial one would silently reset whatever it omitted. + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .updateSavedView(listId: "L1", viewId: "v-1", name: "New", hasConfig: false, isDefault: nil) + ) + } + + func test_givenBlankRename_whenRenaming_thenReportsValidationAndCallsNoService() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", name: "Old")]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.rename(viewID: "v-1", to: " ") + + XCTAssertNotNil(viewModel.validationMessage) + XCTAssertEqual(viewModel.views.first?.name, "Old") + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1, "Only the load should have reached the service") + } + + func test_givenUpstreamFailure_whenRenaming_thenRestoresTheOriginalName() async { + // Optimistic-UI rollback. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", name: "Old")]) + await stub.enqueueUpdateSavedView(failure: TestError.upstream("forbidden")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.rename(viewID: "v-1", to: "New") + + XCTAssertEqual(viewModel.views.first?.name, "Old") + XCTAssertNotNil(viewModel.error) + } + + func test_givenUnknownViewID_whenRenaming_thenDoesNothing() async { + // Boundary: renaming a row that is not loaded. + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.rename(viewID: "ghost", to: "New") + + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + // MARK: - makeDefault + + func test_givenSecondView_whenMakingItDefault_thenExactlyOneViewIsDefault() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", isDefault: true), + ListsFixtures.savedView(id: "v-2") + ]) + await stub.enqueueUpdateSavedView(success: ListsFixtures.savedView(id: "v-2", isDefault: true)) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.makeDefault(viewID: "v-2") + + XCTAssertEqual(viewModel.views.filter(\.isDefault).map(\.id), ["v-2"]) + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .updateSavedView(listId: "L1", viewId: "v-2", name: nil, hasConfig: false, isDefault: true) + ) + } + + func test_givenUpstreamFailure_whenMakingDefault_thenRestoresThePreviousDefault() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", isDefault: true), + ListsFixtures.savedView(id: "v-2") + ]) + await stub.enqueueUpdateSavedView(failure: TestError.upstream("nope")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.makeDefault(viewID: "v-2") + + XCTAssertEqual(viewModel.views.filter(\.isDefault).map(\.id), ["v-1"]) + XCTAssertNotNil(viewModel.error) + } + + func test_givenUnknownViewID_whenMakingDefault_thenCallsNoService() async { + // Invalid input. + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.makeDefault(viewID: "ghost") + + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + // MARK: - setDensity + + func test_givenAppliedView_whenChangingDensity_thenSendsTheWholeConfig() async { + // `PUT` replaces the config object, so the write must carry the + // unconfirmed `filters` / `search` through untouched — dropping them + // would delete arrangement the web may have set. + let stub = StubListsService() + let filters: [ListCellValue] = [.object(["key": .string("read")])] + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", filters: filters, search: "bikes", isDefault: true) + ]) + await stub.enqueueUpdateSavedView( + success: ListsFixtures.savedView(id: "v-1", density: .compact, filters: filters, search: "bikes", isDefault: true) + ) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.setDensity(.compact) + + XCTAssertEqual(viewModel.appliedDensity, .compact) + let sentConfig = await stub.lastUpdatedSavedViewConfig + XCTAssertEqual(sentConfig?.density, .compact) + XCTAssertEqual(sentConfig?.mode, .records) + XCTAssertEqual(sentConfig?.filters, filters) + XCTAssertEqual(sentConfig?.search, "bikes") + } + + func test_givenNoAppliedView_whenChangingDensity_thenCallsNoService() async { + // Invalid input: there is no view to store the change in. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1")]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.setDensity(.compact) + + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenTheSameDensity_whenChangingDensity_thenSkipsTheRoundTrip() async { + // Boundary: a no-op change must not spend a write. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", density: .comfortable, isDefault: true) + ]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.setDensity(.comfortable) + + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenUpstreamFailure_whenChangingDensity_thenRollsBack() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", density: .comfortable, isDefault: true) + ]) + await stub.enqueueUpdateSavedView(failure: TestError.upstream("nope")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.setDensity(.compact) + + XCTAssertEqual(viewModel.appliedDensity, .comfortable) + XCTAssertNotNil(viewModel.error) + } + + // MARK: - delete + + func test_givenLoadedView_whenDeleting_thenRemovesItAndClearsTheSelection() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", isDefault: true)]) + await stub.enqueueDeleteSavedViewSuccess() + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.delete(viewID: "v-1") + + XCTAssertTrue(viewModel.views.isEmpty) + XCTAssertNil(viewModel.selectedViewID) + let recorded = await stub.recorded + XCTAssertEqual(recorded.last?.kind, .deleteSavedView(listId: "L1", viewId: "v-1")) + } + + func test_givenUpstreamFailure_whenDeleting_thenRestoresTheRowAndTheSelection() async { + // Optimistic-UI rollback, including the applied selection. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", isDefault: true)]) + await stub.enqueueDeleteSavedView(failure: TestError.upstream("forbidden")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.delete(viewID: "v-1") + + XCTAssertEqual(viewModel.views.map(\.id), ["v-1"]) + XCTAssertEqual(viewModel.selectedViewID, "v-1") + XCTAssertNotNil(viewModel.error) + } + + func test_givenUnknownViewID_whenDeleting_thenCallsNoService() async { + // Invalid input. + let stub = StubListsService() + await stub.enqueueSavedViews(success: []) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.delete(viewID: "ghost") + + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + // MARK: - fork + + func test_givenSharedView_whenForking_thenAppendsAPersonalCopyAndAppliesIt() async { + // The escape hatch: a collaborator takes the owner's arrangement rather + // than being stuck with it. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1", name: "Owner's board", scope: .shared) + ]) + await stub.enqueueForkSavedView( + success: ListsFixtures.savedView(id: "v-9", name: "Owner's board copy", scope: .personal) + ) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.fork(viewID: "v-1", name: "Owner's board copy") + + XCTAssertEqual(viewModel.personalViews.map(\.id), ["v-9"]) + XCTAssertEqual(viewModel.selectedViewID, "v-9") + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .forkSavedView(listId: "L1", viewId: "v-1", name: "Owner's board copy") + ) + } + + func test_givenBlankForkName_whenForking_thenReportsValidationAndCallsNoService() async { + // Invalid input: a *supplied* blank name is a mistake; a nil one is + // legal and lets the server pick. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", scope: .shared)]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.fork(viewID: "v-1", name: " ") + + XCTAssertNotNil(viewModel.validationMessage) + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenNoForkName_whenForking_thenLetsTheServerName() async { + // Boundary. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", scope: .shared)]) + await stub.enqueueForkSavedView(success: ListsFixtures.savedView(id: "v-9", name: "Copy of board")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.fork(viewID: "v-1", name: nil) + + XCTAssertEqual(viewModel.views.map(\.id), ["v-1", "v-9"]) + let recorded = await stub.recorded + XCTAssertEqual(recorded.last?.kind, .forkSavedView(listId: "L1", viewId: "v-1", name: nil)) + } + + func test_givenUpstreamFailure_whenForking_thenAddsNothing() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", scope: .shared)]) + await stub.enqueueForkSavedView(failure: TestError.upstream("nope")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.fork(viewID: "v-1", name: "Copy") + + XCTAssertEqual(viewModel.views.map(\.id), ["v-1"]) + XCTAssertNotNil(viewModel.error) + } + + // MARK: - Event-bus routing + + func test_givenSavedViewsChangedForThisList_whenApplying_thenReplacesTheCollection() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1")]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + viewModel.apply(event: .savedViewsChanged( + listId: "L1", + views: [ListsFixtures.savedView(id: "v-2", isDefault: true)] + )) + + XCTAssertEqual(viewModel.views.map(\.id), ["v-2"]) + // The applied view was deleted in the other window, so fall back to the + // new default rather than leaving a dangling selection. + XCTAssertEqual(viewModel.selectedViewID, "v-2") + } + + func test_givenSavedViewsChangedForAnotherList_whenApplying_thenIsANoOp() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1")]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + viewModel.apply(event: .savedViewsChanged(listId: "OTHER", views: [])) + + XCTAssertEqual(viewModel.views.map(\.id), ["v-1"]) + } + + func test_givenTheAppliedViewSurvives_whenApplyingAnUpdate_thenKeepsTheSelection() async { + // Boundary: another window renamed a *different* view; the selection + // here must not jump. + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ + ListsFixtures.savedView(id: "v-1"), + ListsFixtures.savedView(id: "v-2", isDefault: true) + ]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + viewModel.select(viewID: "v-1") + + viewModel.apply(event: .savedViewsChanged(listId: "L1", views: [ + ListsFixtures.savedView(id: "v-1"), + ListsFixtures.savedView(id: "v-2", name: "Renamed elsewhere", isDefault: true) + ])) + + XCTAssertEqual(viewModel.selectedViewID, "v-1") + XCTAssertEqual(viewModel.views.last?.name, "Renamed elsewhere") + } + + func test_givenListDeleted_whenApplying_thenClearsEverything() async { + let stub = StubListsService() + await stub.enqueueSavedViews(success: [ListsFixtures.savedView(id: "v-1", isDefault: true)]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + viewModel.apply(event: .listDeleted(id: "L1")) + + XCTAssertTrue(viewModel.views.isEmpty) + XCTAssertNil(viewModel.selectedViewID) + } +} diff --git a/AppTests/Support/StubListsService.swift b/AppTests/Support/StubListsService.swift index c92e8a2..6465848 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -48,6 +48,12 @@ struct RecordedListsCall: Sendable, Equatable { case connections(listId: String?) case addConnection(from: String, to: String, label: String?) case removeConnection(id: String) + // G40 saved views (issue #81) + case savedViews(listId: String) + case createSavedView(listId: String, name: String, scope: SavedListViewScope, isDefault: Bool) + case updateSavedView(listId: String, viewId: String, name: String?, hasConfig: Bool, isDefault: Bool?) + case deleteSavedView(listId: String, viewId: String) + case forkSavedView(listId: String, viewId: String, name: String?) } let kind: Kind } @@ -83,6 +89,19 @@ actor StubListsService: ListsServicing { private var removeConnectionOutcomes: [Result] = [] private var publicListOutcomes: [Result] = [] private var publicRowsOutcomes: [Result] = [] + private var savedViewsOutcomes: [Result<[SavedListView], Error>] = [] + private var createSavedViewOutcomes: [Result] = [] + private var updateSavedViewOutcomes: [Result] = [] + private var deleteSavedViewOutcomes: [Result] = [] + private var forkSavedViewOutcomes: [Result] = [] + + /// The full `SavedListViewConfig` passed to the most recent + /// `updateSavedView`. The recorded-call log only captures *whether* a + /// config was sent; tests asserting that a density change carried the + /// unconfirmed `filters` / `search` through untouched read this instead — + /// `PUT` replaces the config whole, so what exactly was sent is the + /// correctness question. + private(set) var lastUpdatedSavedViewConfig: SavedListViewConfig? /// Cache-first read surface (PLAN.md §5 SWR). Default `[]` so unprepared /// paths behave like a cold cache; set a value to prime a paint-first test @@ -172,6 +191,17 @@ actor StubListsService: ListsServicing { func enqueuePublicRows(success page: RowsPage) { publicRowsOutcomes.append(.success(page)) } func enqueuePublicRows(failure error: Error) { publicRowsOutcomes.append(.failure(error)) } + func enqueueSavedViews(success views: [SavedListView]) { savedViewsOutcomes.append(.success(views)) } + func enqueueSavedViews(failure error: Error) { savedViewsOutcomes.append(.failure(error)) } + func enqueueCreateSavedView(success view: SavedListView) { createSavedViewOutcomes.append(.success(view)) } + func enqueueCreateSavedView(failure error: Error) { createSavedViewOutcomes.append(.failure(error)) } + func enqueueUpdateSavedView(success view: SavedListView) { updateSavedViewOutcomes.append(.success(view)) } + func enqueueUpdateSavedView(failure error: Error) { updateSavedViewOutcomes.append(.failure(error)) } + func enqueueDeleteSavedViewSuccess() { deleteSavedViewOutcomes.append(.success(())) } + func enqueueDeleteSavedView(failure error: Error) { deleteSavedViewOutcomes.append(.failure(error)) } + func enqueueForkSavedView(success view: SavedListView) { forkSavedViewOutcomes.append(.success(view)) } + func enqueueForkSavedView(failure error: Error) { forkSavedViewOutcomes.append(.failure(error)) } + // MARK: ListsServicing — public browse func publicLists(username: String, limit: Int, offset: Int) async throws -> ListsPage { @@ -331,6 +361,52 @@ actor StubListsService: ListsServicing { let _: Void = try take(&removeConnectionOutcomes, label: "removeConnection") } + // MARK: ListsServicing — saved views (G40) + + func savedViews(of listId: String) async throws -> [SavedListView] { + recorded.append(.init(kind: .savedViews(listId: listId))) + return try take(&savedViewsOutcomes, label: "savedViews") + } + + func createSavedView( + listId: String, + name: String, + scope: SavedListViewScope, + config: SavedListViewConfig, + isDefault: Bool + ) async throws -> SavedListView { + recorded.append(.init(kind: .createSavedView(listId: listId, name: name, scope: scope, isDefault: isDefault))) + return try take(&createSavedViewOutcomes, label: "createSavedView") + } + + func updateSavedView( + listId: String, + viewId: String, + name: String?, + config: SavedListViewConfig?, + isDefault: Bool? + ) async throws -> SavedListView { + recorded.append(.init(kind: .updateSavedView( + listId: listId, + viewId: viewId, + name: name, + hasConfig: config != nil, + isDefault: isDefault + ))) + lastUpdatedSavedViewConfig = config + return try take(&updateSavedViewOutcomes, label: "updateSavedView") + } + + func deleteSavedView(listId: String, viewId: String) async throws { + recorded.append(.init(kind: .deleteSavedView(listId: listId, viewId: viewId))) + let _: Void = try take(&deleteSavedViewOutcomes, label: "deleteSavedView") + } + + func forkSavedView(listId: String, viewId: String, name: String?) async throws -> SavedListView { + recorded.append(.init(kind: .forkSavedView(listId: listId, viewId: viewId, name: name))) + return try take(&forkSavedViewOutcomes, label: "forkSavedView") + } + // MARK: - Internals private func take(_ queue: inout [Result], label: String) throws -> T { @@ -480,4 +556,38 @@ enum ListsFixtures { ) -> ListConnection { ListConnection(id: id, fromListId: from, toListId: to, label: label) } + + // MARK: - G40 saved-view fixtures + + /// A saved view in the live eight-field shape. Defaults mirror the server's + /// own create default (`records` / `comfortable` / no filters), so a test + /// that does not care about the arrangement gets a realistic one. + static func savedView( + id: String, + listID: String = "L1", + ownerID: String = "u-owner", + name: String = "View", + scope: SavedListViewScope = .personal, + density: SavedListViewDensity = .comfortable, + filters: [ListCellValue] = [], + search: String? = nil, + isDefault: Bool = false, + position: Int = 0 + ) -> SavedListView { + SavedListView( + id: id, + listID: listID, + ownerID: ownerID, + name: name, + scope: scope, + config: SavedListViewConfig( + mode: .records, + density: density, + filters: filters, + search: search + ), + isDefault: isDefault, + position: position + ) + } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift index afe6870..cdd7118 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift @@ -107,15 +107,20 @@ extension RowsPage { } } - -// MARK: - Wire projection helper +// MARK: - Domain → wire projection /// Recursive projection from the domain's loose `ListCellValue` back to the /// kit's `ListJSONValue` — the inverse of `ListCellValue.init(from:)` above. /// -/// Used when writing a row and when serialising a column's `defaultValue`. It -/// lived `fileprivate` in `ListsService.swift`, which meant the schema mappers -/// could not reuse it; the two directions belong side by side. +/// Used whenever the client writes loose JSON: row cells, a column's +/// `defaultValue`, and the saved-view `config.filters` whose element grammar is +/// unconfirmed. +/// +/// Moved here from `ListsService.swift` (where it was `fileprivate`) once a +/// second writer appeared — two files needing the same projection is exactly the +/// point at which a private copy becomes a duplicated one, and a drifted +/// duplicate of a lossless round-trip would silently corrupt whatever it +/// disagreed about. Sits next to its inverse so the pair is audited together. extension ListJSONValue { init(from value: ListCellValue) { switch value { @@ -125,6 +130,8 @@ extension ListJSONValue { case .double(let v): self = .double(v) case .string(let v): self = .string(v) case .array(let items): + // Explicit closures, not `ListJSONValue.init(from:)` — that + // reference is ambiguous against `Decodable.init(from:)`. self = .array(items.map { ListJSONValue(from: $0) }) case .object(let dict): self = .object(dict.mapValues { ListJSONValue(from: $0) }) diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SavedListView.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SavedListView.swift new file mode 100644 index 0000000..d3e67ad --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SavedListView.swift @@ -0,0 +1,272 @@ +import Foundation +import InterlinedKit + +// MARK: - SavedListViewScope + +/// Who a saved view belongs to (work-consolidation.md G40 / issue #81). +/// +/// This is the collaboration model the feature exists for: a `shared` view +/// belongs to the *list*, so everyone with access sees it; a `personal` view +/// belongs to the *caller*. `GET …/views` returns both in one array, so the +/// UI must render this discriminator — without it a collaborator cannot tell +/// the owner's arrangement from their own. +/// +/// **No `.unknown` case, deliberately.** Unlike `mode` and `density`, `scope` +/// is validated server-side: a live `POST` with `"scope":"bogus_scope"` +/// answered `400 {"error":"scope must be \"personal\" or \"shared\"", +/// "code":"bad_request"}` (2026-09-15). Preserving an unrecognised token for +/// round-trip would therefore guarantee a 400 on the next write rather than +/// protect anything — the opposite of what the escape hatch on +/// `ViewingPreference.other` buys. An unreadable token collapses to +/// `.personal` in the mapper instead; see `SavedListView.init(from:)`. +public enum SavedListViewScope: String, Sendable, Equatable, Hashable, CaseIterable { + /// Visible only to the caller. + case personal + /// Belongs to the list; visible to everyone with access to it. + case shared + + /// Human label for the scope badge. + public var displayName: String { + switch self { + case .personal: return "Personal" + case .shared: return "Shared" + } + } +} + +// MARK: - SavedListViewMode + +/// How a saved view asks the list to be laid out. +/// +/// **Only `records` was ever accepted.** A live probe sent `table`, `gallery`, +/// `kanban`, `board`, `grid`, `list` and `cards`; every one returned HTTP 200 +/// and read back as `"records"` (2026-09-15). The server silently normalises +/// instead of rejecting, which is why `.unknown` is a real state and not +/// defensive padding: if a later server starts storing a mode this build does +/// not know, the only alternative to surfacing it is to overwrite the user's +/// choice with `records` on the next save. +public enum SavedListViewMode: Sendable, Equatable, Hashable { + /// The one mode the server actually stores today. + case records + /// A token this build does not recognise, carried verbatim. + case unknown(String) + + public init(wireToken: String) { + switch wireToken.trimmingCharacters(in: .whitespacesAndNewlines) { + case "records": self = .records + case let token: self = .unknown(token) + } + } + + public var wireToken: String { + switch self { + case .records: return "records" + case .unknown(let token): return token + } + } + + public var displayName: String { + switch self { + case .records: return "Records" + case .unknown(let token): return token + } + } +} + +// MARK: - SavedListViewDensity + +/// Row spacing a saved view asks for. +/// +/// **Accepted live: `comfortable` (the create default) and `compact`.** +/// `spacious`, `cozy`, `dense` and `comfy` each returned 200 and silently fell +/// back (2026-09-15). Same reasoning as `SavedListViewMode` for `.unknown`. +public enum SavedListViewDensity: Sendable, Equatable, Hashable { + case comfortable + case compact + /// A token this build does not recognise, carried verbatim. + case unknown(String) + + public init(wireToken: String) { + switch wireToken.trimmingCharacters(in: .whitespacesAndNewlines) { + case "comfortable": self = .comfortable + case "compact": self = .compact + case let token: self = .unknown(token) + } + } + + public var wireToken: String { + switch self { + case .comfortable: return "comfortable" + case .compact: return "compact" + case .unknown(let token): return token + } + } + + public var displayName: String { + switch self { + case .comfortable: return "Comfortable" + case .compact: return "Compact" + case .unknown(let token): return token + } + } + + /// The two densities a user may pick. `.unknown` is never offered — it + /// only ever arrives from the server. + public static let selectable: [SavedListViewDensity] = [.comfortable, .compact] +} + +// MARK: - SavedListViewConfig + +/// The arrangement a saved view stores. +/// +/// **Exactly four keys, because the server is a whitelist.** A live probe sent +/// `columns`, `visibleColumns`, `columnOrder`, `hiddenColumns`, `groupBy`, +/// `sort`, `sortBy`, `sortDirection`, `rowHeight` and a deliberate `bogusKey`; +/// all ten were stripped without an error (2026-09-15). Issue #81 presumed +/// `config` encoded column order / visibility / sort — it does not, and +/// modelling those would be modelling storage that does not exist. +/// +/// A *value* type rather than a bag of optionals: because `PUT` replaces the +/// config whole (omitting `density` on a live PUT reset it from `compact` to +/// `comfortable`), every write must state the complete arrangement. Making +/// `mode` and `density` non-optional makes that structurally impossible to get +/// wrong — there is no way to construct a half-config and send it. +public struct SavedListViewConfig: Sendable, Equatable { + + public var mode: SavedListViewMode + public var density: SavedListViewDensity + + /// Stored filters, **grammar unconfirmed**, carried as opaque JSON so + /// whatever the web wrote survives a macOS round-trip untouched. + /// + /// The recon account's only list has an empty schema (zero columns), so + /// every probe filter named a column that does not exist and was dropped — + /// leaving a grammar failure and a column-not-found indistinguishable. The + /// OpenAPI example's `{key, op, value}` shape is not evidence either: that + /// exact object was sent live and came back `[]`. Until a populated list is + /// available to probe, this client must not invent an element type and must + /// not drop what it cannot parse. + public var filters: [ListCellValue] + + /// A stored search string. Whitelisted by the server but never observed + /// populated, so optional. + public var search: String? + + public init( + mode: SavedListViewMode = .records, + density: SavedListViewDensity = .comfortable, + filters: [ListCellValue] = [], + search: String? = nil + ) { + self.mode = mode + self.density = density + self.filters = filters + self.search = search + } + + /// The arrangement the server applies when a create omits `config` + /// entirely — verified live: `{"mode":"records","density":"comfortable", + /// "filters":[]}`. Named rather than inlined so a UI default and the + /// server default can never drift apart silently. + public static let serverDefault = SavedListViewConfig() +} + +// MARK: - SavedListView + +/// One saved, named arrangement of a list. +/// +/// Backed by `GET/POST/PUT/DELETE /api/lists/{id}/views*`. Free on every tier +/// and Bearer-reachable — see `Lists.views(listId:)` for why no entitlement +/// gate belongs here. +public struct SavedListView: Sendable, Equatable, Identifiable { + + public let id: String + /// The list this view arranges. + public let listID: String + /// Whoever created the view. On a `shared` view that is not necessarily + /// the caller, so never read this as "mine" — `scope` is the discriminator + /// the UI renders. + public let ownerID: String + public let name: String + public let scope: SavedListViewScope + public let config: SavedListViewConfig + /// **Per user.** Two people on the same shared list can each mark a + /// different view as their default, which is why this rides on the view + /// rather than on the list. + public let isDefault: Bool + /// The server's ordering hint within a scope bucket. A personal view and a + /// shared view were both observed at `position: 0`, so it is not unique + /// across a response — display order comes from the server's array order, + /// never from sorting on this. + public let position: Int + + public init( + id: String, + listID: String, + ownerID: String, + name: String, + scope: SavedListViewScope, + config: SavedListViewConfig, + isDefault: Bool, + position: Int + ) { + self.id = id + self.listID = listID + self.ownerID = ownerID + self.name = name + self.scope = scope + self.config = config + self.isDefault = isDefault + self.position = position + } + + /// Whether this view is the list's, not the caller's — drives the shared + /// badge and the "fork it rather than edit it" affordance. + public var isShared: Bool { scope == .shared } +} + +// MARK: - DTO → domain mapping + +extension SavedListViewConfig { + public init(from dto: ListViewConfigDTO) { + self.init( + mode: SavedListViewMode(wireToken: dto.mode), + density: SavedListViewDensity(wireToken: dto.density), + filters: dto.filters.map(ListCellValue.init(from:)), + search: dto.search + ) + } + + /// Projects back to the wire shape. Always emits the complete object + /// because `PUT` replaces rather than merges. + public var wireValue: ListViewConfigDTO { + ListViewConfigDTO( + mode: mode.wireToken, + density: density.wireToken, + filters: filters.map(ListJSONValue.init(from:)), + search: search + ) + } +} + +extension SavedListView { + /// Maps one `ListViewDTO`. + /// + /// An unrecognised `scope` collapses to `.personal`: mislabelling someone's + /// private arrangement as shared would tell the user their filters are + /// visible to collaborators when they are not, and that is the more harmful + /// of the two possible mistakes. The server validates the field on write, + /// so this branch should be unreachable in practice. + public init(from dto: ListViewDTO) { + self.init( + id: dto.id, + listID: dto.listId, + ownerID: dto.userId, + name: dto.name, + scope: SavedListViewScope(rawValue: dto.scope) ?? .personal, + config: SavedListViewConfig(from: dto.config), + isDefault: dto.isDefault, + position: dto.position + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift index 0d804ef..c724f9c 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -29,6 +29,13 @@ public enum ListsError: Error, Sendable, Equatable { /// which is a different action entirely (work-consolidation.md G23). case invalidWatcher + /// A saved-view create, rename or fork supplied a blank name. Raised before + /// any HTTP call: the views routes do not reject an empty `name`, so the + /// round-trip would succeed and leave an unlabelled row in a picker the + /// user then cannot tell apart from the next one + /// (work-consolidation.md G40). + case invalidViewName + /// A schema rebuild would drop a column that still holds row data, and the /// server refused it pending confirmation. Re-submit with `force: true` to /// accept the data loss. @@ -50,6 +57,8 @@ extension ListsError: LocalizedError, CustomStringConvertible { return "Schema \"\(raw)\" could not be parsed: \(reason.description)" case .invalidWatcher: return "Choose a person to share this list with." + case .invalidViewName: + return "Give this view a name." case .schemaChangeWouldLoseData(let serverMessage): return serverMessage ?? "This change would delete columns that still hold data. Confirm to continue." @@ -266,6 +275,59 @@ public protocol ListsServicing: Sendable { /// Removes a connection by id. func removeConnection(connectionId: String) async throws + + // MARK: - G40 saved views + + /// Loads every saved view on a list: the list's **shared** views plus the + /// caller's **personal** ones, in the server's array order. + /// + /// Unpaged, and deliberately not sorted client-side — `position` repeats + /// across scope buckets, so re-sorting on it would shuffle the two sets + /// together (work-consolidation.md G40). + func savedViews(of listId: String) async throws -> [SavedListView] + + /// Creates a saved view. Throws `ListsError.invalidViewName` on a blank + /// name before any HTTP call. + /// + /// Free on every tier — the views routes are `x-subscription-tier: free`. + func createSavedView( + listId: String, + name: String, + scope: SavedListViewScope, + config: SavedListViewConfig, + isDefault: Bool + ) async throws -> SavedListView + + /// Updates a saved view's name, arrangement and/or default flag. `nil` + /// leaves that field untouched. + /// + /// - Important: a non-nil `config` **replaces** the stored arrangement + /// whole, so pass the complete config you want to end up with. The + /// parameter takes a `SavedListViewConfig`, which cannot be partial, so + /// this is enforced by the type rather than by the caller remembering. + func updateSavedView( + listId: String, + viewId: String, + name: String?, + config: SavedListViewConfig?, + isDefault: Bool? + ) async throws -> SavedListView + + /// Deletes a saved view. + func deleteSavedView(listId: String, viewId: String) async throws + + /// Forks a view into a personal copy owned by the caller — the spec's + /// "escape hatch" from the list owner's arrangement. + /// + /// Works on a personal source view too, not only a shared one (verified + /// live 2026-09-15), so the UI may offer it as plain "duplicate". A `nil` + /// name lets the server pick one; a supplied-but-blank name throws + /// `ListsError.invalidViewName`. + func forkSavedView( + listId: String, + viewId: String, + name: String? + ) async throws -> SavedListView } // MARK: - ListsService @@ -721,6 +783,79 @@ public final class ListsService: ListsServicing { try await api.sendVoid(Lists.deleteConnection(id: connectionId)) } + // MARK: - G40 saved views + + /// Free on every tier: the five views routes are declared + /// `x-subscription-tier: free` and were all reached live on a free account + /// with a Bearer token (2026-09-15). No `requireListManagement()` call + /// belongs on any of them — arranging a list you can already read is not + /// creating one (GitHub #40 matrix). + public func savedViews(of listId: String) async throws -> [SavedListView] { + let response = try await api.send(Lists.views(listId: listId)) + // Server order, verbatim. See the protocol doc for why `position` is + // not a sort key. + return response.views.map(SavedListView.init(from:)) + } + + public func createSavedView( + listId: String, + name: String, + scope: SavedListViewScope, + config: SavedListViewConfig, + isDefault: Bool + ) async throws -> SavedListView { + let trimmed = try requireViewName(name) + let request = CreateListViewRequest( + name: trimmed, + // `scope` is the one field the server validates — an unknown token + // is a hard 400 — so it is sent from the closed domain enum rather + // than from any caller-supplied string. + scope: scope.rawValue, + config: config.wireValue, + isDefault: isDefault + ) + let response = try await api.send(Lists.createView(listId: listId, request)) + // Believe the server's row, not the optimistic local one: unknown + // `mode` / `density` values and every filter are silently normalised on + // write, so the request and the stored view routinely disagree. + return SavedListView(from: response.view) + } + + public func updateSavedView( + listId: String, + viewId: String, + name: String?, + config: SavedListViewConfig?, + isDefault: Bool? + ) async throws -> SavedListView { + // A supplied name must be meaningful; an absent one means "don't + // touch the name", which is a different thing entirely. + let trimmedName = try name.map(requireViewName) + let request = UpdateListViewRequest( + name: trimmedName, + config: config?.wireValue, + isDefault: isDefault + ) + let response = try await api.send(Lists.updateView(listId: listId, viewId: viewId, request)) + return SavedListView(from: response.view) + } + + public func deleteSavedView(listId: String, viewId: String) async throws { + try await api.sendVoid(Lists.deleteView(listId: listId, viewId: viewId)) + } + + public func forkSavedView( + listId: String, + viewId: String, + name: String? + ) async throws -> SavedListView { + let trimmedName = try name.map(requireViewName) + let response = try await api.send( + Lists.forkView(listId: listId, viewId: viewId, ForkListViewRequest(name: trimmedName)) + ) + return SavedListView(from: response.view) + } + // MARK: - Internals /// The subscriber gate for list **creation**, and only creation. @@ -741,6 +876,18 @@ public final class ListsService: ListsServicing { } } + /// Trims a saved-view name and rejects a blank one before any HTTP call. + /// + /// The route accepts `""` happily, so the server will not catch this: a + /// blank create lands an unlabelled row in the views picker that the user + /// cannot tell apart from the next blank one, and cannot rename without + /// first identifying. Cheaper to refuse here (work-consolidation.md G40). + private func requireViewName(_ name: String) throws -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ListsError.invalidViewName } + return trimmed + } + /// Parses a DSL string into a `ListSchema`, projecting `SchemaDSLError` /// into the richer `ListsError.malformedSchema` so the editor can /// surface both the raw string and the precise reason. @@ -756,4 +903,5 @@ public final class ListsService: ListsServicing { // The `ListCellValue` → `ListJSONValue` projection this file uses when writing // rows moved to `ListMappers.swift`, next to its inverse. It was `fileprivate` // here, which stopped the schema mappers reusing it for a column's -// `defaultValue` (GitHub #85). +// `defaultValue` (GitHub #85) and left saved-view filters without one at all +// (G40). diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SavedListViewsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SavedListViewsServiceTests.swift new file mode 100644 index 0000000..3d84807 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SavedListViewsServiceTests.swift @@ -0,0 +1,467 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for the saved-list-views domain surface +/// (work-consolidation.md G40 / issue #81): +/// `ListsService.savedViews` / `createSavedView` / `updateSavedView` / +/// `deleteSavedView` / `forkSavedView`, plus the `SavedListView*` mappers. +/// +/// Envelopes are the live payloads captured on 2026-09-15; the `{"views":[]}` +/// empty case was re-read on 2026-09-16. Quartet (happy / invalid input / +/// upstream failure / boundary) per method. +final class SavedListViewsServiceTests: XCTestCase { + + /// The live eight-key row. No `createdAt` / `updatedAt` — the spec marks + /// both required and the API sends neither. + private func viewRow( + id: String = "v-1", + name: String = "Reading", + scope: String = "personal", + mode: String = "records", + density: String = "comfortable", + filters: String = "[]", + isDefault: Bool = false, + position: Int = 0 + ) -> String { + """ + {"id":"\(id)","listId":"L1","userId":"c65","name":"\(name)","scope":"\(scope)", + "config":{"mode":"\(mode)","density":"\(density)","filters":\(filters)}, + "isDefault":\(isDefault),"position":\(position)} + """ + } + + // MARK: - savedViews + + func test_givenSharedAndPersonalViews_whenLoading_thenMapsScopeConfigAndDefault() async throws { + // Given — the collection route mixes both scopes in one array, which is + // exactly why the UI has to render the discriminator. + let api = StubAPIClient() + await api.enqueue(json: """ + {"views":[ + \(viewRow(id: "v-1", name: "Team board", scope: "shared", density: "compact")), + \(viewRow(id: "v-2", name: "Mine", scope: "personal", isDefault: true, position: 1)) + ]} + """) + let service = ListsService(api: api) + + // When + let views = try await service.savedViews(of: "L1") + + // Then + XCTAssertEqual(views.map(\.id), ["v-1", "v-2"]) + XCTAssertEqual(views.map(\.scope), [.shared, .personal]) + XCTAssertEqual(views.first?.config.density, .compact) + XCTAssertEqual(views.first?.config.mode, .records) + XCTAssertTrue(views.first?.isShared ?? false) + XCTAssertEqual(views.last?.isDefault, true) + XCTAssertEqual(views.first?.ownerID, "c65") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "GET") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/views") + } + + func test_givenUnknownModeAndDensity_whenLoading_thenCarriesTheTokensRatherThanRewritingThem() async throws { + // Invalid input from upstream. The server *silently defaults* unknown + // config values instead of erroring, so a token this build does not know + // is a real state: collapsing it to `.records` / `.comfortable` would + // overwrite the user's stored choice on the next save. + let api = StubAPIClient() + await api.enqueue(json: """ + {"views":[\(viewRow(mode: "kanban", density: "spacious"))]} + """) + let service = ListsService(api: api) + + let views = try await service.savedViews(of: "L1") + + XCTAssertEqual(views.first?.config.mode, .unknown("kanban")) + XCTAssertEqual(views.first?.config.density, .unknown("spacious")) + // …and the tokens survive the round-trip back to the wire. + XCTAssertEqual(views.first?.config.wireValue.mode, "kanban") + XCTAssertEqual(views.first?.config.wireValue.density, "spacious") + } + + func test_givenUnrecognisedScope_whenLoading_thenTreatsItAsPersonal() async throws { + // Invalid input from upstream. Of the two possible mistakes, calling a + // shared view personal is the safe one: the opposite would tell the user + // their private filters are visible to collaborators. + let api = StubAPIClient() + await api.enqueue(json: #"{"views":[\#(viewRow(scope: "organisation"))]}"#) + let service = ListsService(api: api) + + let views = try await service.savedViews(of: "L1") + + XCTAssertEqual(views.first?.scope, .personal) + XCTAssertFalse(views.first?.isShared ?? true) + } + + func test_givenOpaqueFilters_whenLoading_thenRoundTripsThemUntouched() async throws { + // The filters element grammar is UNCONFIRMED — the recon list has an + // empty schema, so every probe filter was dropped and a grammar failure + // could not be told from a column-not-found. The client therefore has + // to preserve whatever the web wrote rather than parse it. + let api = StubAPIClient() + await api.enqueue(json: """ + {"views":[\(viewRow(filters: #"[{"key":"read","op":"eq","value":false}]"#))]} + """) + let service = ListsService(api: api) + + let views = try await service.savedViews(of: "L1") + + let filters = try XCTUnwrap(views.first?.config.filters) + XCTAssertEqual(filters.count, 1) + guard case .object(let first) = filters[0] else { + return XCTFail("Expected the filter to survive as an opaque object") + } + XCTAssertEqual(first["key"], .string("read")) + XCTAssertEqual(first["op"], .string("eq")) + XCTAssertEqual(first["value"], .bool(false)) + // Back out to the wire unchanged. + let wire = try XCTUnwrap(views.first?.config.wireValue.filters) + XCTAssertEqual(wire, [.object(["key": .string("read"), "op": .string("eq"), "value": .bool(false)])]) + } + + func test_givenListWithNoViews_whenLoading_thenReturnsEmpty() async throws { + // Boundary — the literal live body. + let api = StubAPIClient() + await api.enqueue(json: #"{"views":[]}"#) + let service = ListsService(api: api) + + let views = try await service.savedViews(of: "L1") + + XCTAssertTrue(views.isEmpty) + } + + func test_givenUpstreamFailure_whenLoading_thenPropagatesTheAPIError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .unauthorized(serverMessage: "Unauthorized")) + let service = ListsService(api: api) + + do { + _ = try await service.savedViews(of: "L1") + XCTFail("Expected APIError.unauthorized") + } catch let error as APIError { + XCTAssertEqual(error, .unauthorized(serverMessage: "Unauthorized")) + } + } + + // MARK: - createSavedView + + func test_givenNamedPersonalView_whenCreating_thenSendsScopeTokenAndConfigObject() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(name: "Reading"))}"#) + let service = ListsService(api: api) + + let created = try await service.createSavedView( + listId: "L1", + name: "Reading", + scope: .personal, + config: SavedListViewConfig(mode: .records, density: .comfortable), + isDefault: false + ) + + XCTAssertEqual(created.id, "v-1") + XCTAssertEqual(created.scope, .personal) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/views") + let body = try await lastSentJSON(api) + XCTAssertEqual(body["name"] as? String, "Reading") + XCTAssertEqual(body["scope"] as? String, "personal") + // `config` must be an object — the OpenAPI request body declares it a + // string, which is what issue #81 was filed on, and a string is + // rejected. + let config = try XCTUnwrap(body["config"] as? [String: Any]) + XCTAssertEqual(config["mode"] as? String, "records") + XCTAssertEqual(config["density"] as? String, "comfortable") + } + + func test_givenBlankName_whenCreating_thenThrowsWithoutCallingTheAPI() async throws { + // Invalid input. The route happily accepts "" — nothing server-side + // stops an unlabelled row landing in the picker — so the guard has to + // be here, and it has to fire before the round-trip. + let api = StubAPIClient() + let service = ListsService(api: api) + + do { + _ = try await service.createSavedView( + listId: "L1", + name: " ", + scope: .personal, + config: .serverDefault, + isDefault: false + ) + XCTFail("Expected ListsError.invalidViewName") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidViewName) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A blank name must not spend a round-trip") + } + + func test_givenSurroundingWhitespace_whenCreating_thenSendsTheTrimmedName() async throws { + // Boundary: a name that is only *nearly* blank is legal, trimmed. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(name: "Reading"))}"#) + let service = ListsService(api: api) + + _ = try await service.createSavedView( + listId: "L1", + name: " Reading ", + scope: .shared, + config: .serverDefault, + isDefault: false + ) + + let body = try await lastSentJSON(api) + XCTAssertEqual(body["name"] as? String, "Reading") + XCTAssertEqual(body["scope"] as? String, "shared") + } + + func test_givenServerRejectingScope_whenCreating_thenPropagatesTheBadRequest() async throws { + // Upstream failure, and the asymmetry the client is built around: + // `scope` hard-fails where every config value defaults silently. + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: #"scope must be "personal" or "shared""#)) + let service = ListsService(api: api) + + do { + _ = try await service.createSavedView( + listId: "L1", + name: "Reading", + scope: .personal, + config: .serverDefault, + isDefault: false + ) + XCTFail("Expected APIError.badRequest") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: #"scope must be "personal" or "shared""#)) + } + } + + func test_givenServerNormalisingTheConfig_whenCreating_thenReturnsTheStoredArrangement() async throws { + // The request and the stored view routinely disagree — an unknown mode + // comes back as `records`. The caller must get the server's row, never + // its own optimistic copy. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(mode: "records"))}"#) + let service = ListsService(api: api) + + let created = try await service.createSavedView( + listId: "L1", + name: "Board", + scope: .personal, + config: SavedListViewConfig(mode: .unknown("kanban"), density: .compact), + isDefault: false + ) + + XCTAssertEqual(created.config.mode, .records) + XCTAssertEqual(created.config.density, .comfortable) + } + + // MARK: - updateSavedView + + func test_givenFullConfig_whenUpdating_thenSendsEveryConfigKey() async throws { + // PUT replaces the config whole: a live PUT that omitted `density` + // reset it from "compact" to "comfortable". `SavedListViewConfig` is + // non-partial by construction so a half-config cannot be built. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(density: "compact", isDefault: true))}"#) + let service = ListsService(api: api) + + let updated = try await service.updateSavedView( + listId: "L1", + viewId: "v-1", + name: "Reading", + config: SavedListViewConfig(mode: .records, density: .compact), + isDefault: true + ) + + XCTAssertEqual(updated.config.density, .compact) + XCTAssertTrue(updated.isDefault) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PUT") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/views/v-1") + let body = try await lastSentJSON(api) + let config = try XCTUnwrap(body["config"] as? [String: Any]) + XCTAssertEqual(config["mode"] as? String, "records") + XCTAssertEqual(config["density"] as? String, "compact") + XCTAssertNotNil(config["filters"]) + } + + func test_givenBlankRename_whenUpdating_thenThrowsWithoutCallingTheAPI() async throws { + // Invalid input. + let api = StubAPIClient() + let service = ListsService(api: api) + + do { + _ = try await service.updateSavedView( + listId: "L1", + viewId: "v-1", + name: "", + config: nil, + isDefault: nil + ) + XCTFail("Expected ListsError.invalidViewName") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidViewName) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenOnlyTheDefaultFlag_whenUpdating_thenOmitsNameAndConfig() async throws { + // Boundary: marking a view as the default must not touch its + // arrangement. An omitted `config` leaves the stored one alone; a + // partial one would silently reset whatever it left out. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(isDefault: true))}"#) + let service = ListsService(api: api) + + _ = try await service.updateSavedView( + listId: "L1", + viewId: "v-1", + name: nil, + config: nil, + isDefault: true + ) + + let body = try await lastSentJSON(api) + XCTAssertEqual(body.keys.sorted(), ["isDefault"]) + XCTAssertEqual(body["isDefault"] as? Bool, true) + } + + func test_givenUpstreamFailure_whenUpdating_thenPropagatesTheAPIError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "Forbidden")) + let service = ListsService(api: api) + + do { + _ = try await service.updateSavedView( + listId: "L1", + viewId: "v-1", + name: "Renamed", + config: nil, + isDefault: nil + ) + XCTFail("Expected APIError.forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "Forbidden")) + } + } + + // MARK: - deleteSavedView + + func test_givenExistingView_whenDeleting_thenSendsDelete() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"message":"View deleted"}"#) + let service = ListsService(api: api) + + try await service.deleteSavedView(listId: "L1", viewId: "v-1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/views/v-1") + } + + func test_givenMissingView_whenDeleting_thenPropagatesNotFound() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "View not found")) + let service = ListsService(api: api) + + do { + try await service.deleteSavedView(listId: "L1", viewId: "nope") + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "View not found")) + } + } + + // MARK: - forkSavedView + + func test_givenSharedView_whenForking_thenReturnsAPersonalCopy() async throws { + // The escape hatch: take the owner's arrangement and make it yours + // rather than being stuck with it. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(id: "v-9", name: "My copy", scope: "personal", density: "compact", position: 1))}"#) + let service = ListsService(api: api) + + let forked = try await service.forkSavedView(listId: "L1", viewId: "v-1", name: "My copy") + + XCTAssertEqual(forked.id, "v-9") + XCTAssertEqual(forked.scope, .personal) + XCTAssertEqual(forked.config.density, .compact) + let recorded = await api.recorded + // Same verb as create — only the deeper path distinguishes them. + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/views/v-1") + let body = try await lastSentJSON(api) + XCTAssertEqual(body["name"] as? String, "My copy") + } + + func test_givenBlankForkName_whenForking_thenThrowsWithoutCallingTheAPI() async throws { + // Invalid input: a *supplied* blank name is a mistake. A nil name is + // not — see the boundary case below. + let api = StubAPIClient() + let service = ListsService(api: api) + + do { + _ = try await service.forkSavedView(listId: "L1", viewId: "v-1", name: " ") + XCTFail("Expected ListsError.invalidViewName") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidViewName) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenNoForkName_whenForking_thenSendsAnEmptyBodyAndLetsTheServerName() async throws { + // Boundary: the name is optional on fork. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow(id: "v-9"))}"#) + let service = ListsService(api: api) + + let forked = try await service.forkSavedView(listId: "L1", viewId: "v-1", name: nil) + + XCTAssertEqual(forked.id, "v-9") + let body = try await lastSentJSON(api) + XCTAssertTrue(body.isEmpty) + } + + func test_givenMissingSourceView_whenForking_thenPropagatesNotFound() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "View not found")) + let service = ListsService(api: api) + + do { + _ = try await service.forkSavedView(listId: "L1", viewId: "nope", name: "Copy") + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "View not found")) + } + } + + // MARK: - Free on every tier + + func test_givenFreeAccount_whenUsingSavedViews_thenNoSubscriberGateApplies() async throws { + // The five routes are `x-subscription-tier: free` and were all reached + // live on a free account. Arranging a list you can already read is not + // creating one (GitHub #40 matrix), so a free entitlement must not + // short-circuit any of them before the HTTP call. + let api = StubAPIClient() + await api.enqueue(json: #"{"view":\#(viewRow())}"#) + let service = ListsService(api: api, entitlements: EntitlementsService(customerStatus: .free)) + + let created = try await service.createSavedView( + listId: "L1", + name: "Reading", + scope: .personal, + config: .serverDefault, + isDefault: false + ) + + XCTAssertEqual(created.id, "v-1") + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/StubAPIClient.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/StubAPIClient.swift index 494ac25..0153f73 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/StubAPIClient.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/StubAPIClient.swift @@ -1,4 +1,5 @@ import Foundation +import XCTest import InterlinedKit @testable import InterlinedDomain @@ -49,6 +50,17 @@ actor StubAPIClient: APIClientProtocol { private var outcomes: [Outcome] = [] private(set) var recorded: [RecordedRequest] = [] + /// The encoded `.json` request bodies, in send order, encoded with the same + /// kit encoder production uses. + /// + /// Added for the G40 saved-views tests: `config` must go out as a JSON + /// *object* (the OpenAPI request body wrongly declares it a string), and + /// `PUT` replaces the config whole, so "what exactly did we send" is a + /// correctness question at the service seam and not only at the transport + /// seam. `RecordedRequest` is left untouched so its `Equatable` conformance + /// keeps working for the suites that compare whole requests. + private(set) var sentBodies: [Data] = [] + init() {} // MARK: Programming the stub @@ -123,5 +135,28 @@ actor StubAPIClient: APIClientProtocol { body: bodyData ) ) + if case .json(let value) = request.body, + let encoded = try? JSONCoders.makeEncoder().encode(value) { + sentBodies.append(encoded) + } + } + +} + +// MARK: - Body assertions + +extension XCTestCase { + /// The most recent `.json` body the stub encoded, as a dictionary. + /// + /// Lives on `XCTestCase` rather than on the actor because `[String: Any]` + /// is not `Sendable` and so cannot cross an actor boundary under Swift 6 — + /// the bytes (`sentBodies`) cross instead, and the parse happens test-side. + func lastSentJSON(_ api: StubAPIClient) async throws -> [String: Any] { + let bodies = await api.sentBodies + let data = try XCTUnwrap(bodies.last, "No JSON request body was recorded") + return try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any], + "Recorded request body was not a JSON object" + ) } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListViewDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListViewDTO.swift new file mode 100644 index 0000000..c249730 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListViewDTO.swift @@ -0,0 +1,252 @@ +import Foundation + +// MARK: - ListViewDTO + +/// One **saved list view** — a named, reusable arrangement of a list that +/// either belongs to the caller (`personal`) or to the list itself and +/// therefore to everyone with access (`shared`). Backs the five +/// `/api/lists/{id}/views*` routes (work-consolidation.md G40 / issue #81). +/// +/// **Modelled against a captured payload, not the spec.** The OpenAPI schema +/// for `ListView` disagrees with the live API in two ways that each cost a +/// silent defect if mirrored, so every field below is justified by a recorded +/// response from the test account on 2026-09-15 (envelope key re-confirmed by +/// a `GET` on 2026-09-16, which answered `{"views":[]}`): +/// +/// ```json +/// {"view":{"id":"…","listId":"…","userId":"…","name":"Reading", +/// "scope":"personal", +/// "config":{"mode":"records","density":"comfortable","filters":[]}, +/// "isDefault":false,"position":0}} +/// ``` +/// +/// **Trap 1 — the spec marks `createdAt` / `updatedAt` REQUIRED and the live +/// API never sends them.** POST, GET and PUT all omitted both. A `Decodable` +/// mirroring the schema throws `keyNotFound` on *every* row, so the two are +/// optional here. They are kept rather than deleted because the spec is the +/// stated intent and a server that starts sending them should decode without +/// a client release. +/// +/// **Trap 2 — the spec's response example is not a real payload.** It shows +/// `config.filters: [{"key":"read","op":"eq","value":false}]`; a live POST +/// carrying exactly that filter answered `"filters": []`. Do not build a +/// fixture from the spec example — it describes a shape the server dropped. +/// +/// Every other key is **non-optional on purpose**. All eight were present on +/// every observed response, and the issue's acceptance criterion is that a +/// renamed key must fail the decode rather than degrade to `nil` — the exact +/// failure mode that shipped broken link metadata (G21) and broken org members +/// (G25) behind green tests against fabricated fixtures. +public struct ListViewDTO: Codable, Sendable, Equatable, Identifiable { + + public let id: String + /// The list this view arranges. + public let listId: String + /// The view's owner. On a `shared` view this is whoever created it, not + /// the caller — do not read it as "mine". + public let userId: String + public let name: String + /// `"personal"` or `"shared"`. **The server hard-fails anything else:** + /// a live `POST` with `"scope":"bogus_scope"` answered + /// `400 {"error":"scope must be \"personal\" or \"shared\"","code":"bad_request"}`. + /// Unlike `config`, this key is validated, so the client must send a legal + /// token rather than hoping for a silent default. + public let scope: String + public let config: ListViewConfigDTO + /// Per-user default. Two people on the same shared list can each have a + /// different default view, which is why this rides on the view row rather + /// than the list. + public let isDefault: Bool + /// Ordering within a scope bucket. A personal view and a shared view were + /// both observed at `position: 0`, so it is **not** unique across the + /// response — never key on it. + public let position: Int + + /// Absent from every live response (trap 1). Optional so the decode + /// survives; see the type doc. + public let createdAt: Date? + /// Absent from every live response (trap 1). See `createdAt`. + public let updatedAt: Date? + + public init( + id: String, + listId: String, + userId: String, + name: String, + scope: String, + config: ListViewConfigDTO, + isDefault: Bool, + position: Int, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.listId = listId + self.userId = userId + self.name = name + self.scope = scope + self.config = config + self.isDefault = isDefault + self.position = position + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +// MARK: - ListViewConfigDTO + +/// The arrangement a saved view encodes. +/// +/// **`config` is a JSON object, not a string.** The OpenAPI *request body* +/// declares `"config": {"type": "string"}`; that is a generator artifact — +/// the `ListView` schema itself leaves `config` untyped, and a live `POST` +/// carrying a JSON object returned `201`. Issue #81 was filed on the +/// string reading; it is wrong. +/// +/// **The server is a whitelist of exactly these four keys.** A live probe sent +/// `columns`, `visibleColumns`, `columnOrder`, `hiddenColumns`, `groupBy`, +/// `sort`, `sortBy`, `sortDirection`, `rowHeight` and a deliberate `bogusKey` +/// alongside them; every one was **stripped silently**, with no `400`. So this +/// is a closed struct rather than an opaque bag: an opaque bag would imply the +/// client can persist keys it cannot, and modelling column order / visibility / +/// sort — which issue #81 presumed `config` carried — would model storage that +/// does not exist. +/// +/// **Values default silently where `scope` hard-fails.** Unknown `mode` and +/// `density` tokens return `200` and fall back to the server default rather +/// than erroring, so a client that sends a value it invented gets a view that +/// quietly is not what the user asked for. Both are therefore carried as raw +/// `String` here and projected onto unknown-tolerant domain enums, so an +/// unrecognised token stays visible instead of being rewritten. +public struct ListViewConfigDTO: Codable, Sendable, Equatable { + + /// Observed accepted: `"records"` only. `table`, `gallery`, `kanban`, + /// `board`, `grid`, `list` and `cards` were each sent live and each came + /// back as `"records"` with HTTP 200. + public let mode: String + + /// Observed accepted: `"comfortable"` (the create default) and + /// `"compact"`. `spacious`, `cozy`, `dense` and `comfy` silently fell back. + public let density: String + + /// **Element grammar UNCONFIRMED.** Modelled as opaque JSON so whatever the + /// web writes round-trips through this client untouched. The recon account's + /// only list has an empty schema (zero columns), so every probe filter + /// referenced a column key that does not exist and was dropped — which makes + /// a grammar failure and a column-not-found indistinguishable. The spec's + /// `{key, op, value}` example is **not** evidence: that exact object was + /// sent live and dropped to `[]`. + public let filters: [ListJSONValue] + + /// A stored search string. Whitelisted by the server, but never observed + /// populated (the default config carries only `mode`, `density`, + /// `filters`), so optional. + public let search: String? + + public init( + mode: String, + density: String, + filters: [ListJSONValue] = [], + search: String? = nil + ) { + self.mode = mode + self.density = density + self.filters = filters + self.search = search + } +} + +// MARK: - Response envelopes + +/// `GET /api/lists/{id}/views` → `{"views":[…]}`. +/// +/// The route's own summary describes the collection as "every shared view on +/// the list, plus this user's own personal views" — one flat array mixing both +/// scopes, which is why the UI must render the `scope` discriminator rather +/// than assuming ownership. +public struct ListViewsResponse: Codable, Sendable, Equatable { + public let views: [ListViewDTO] + + public init(views: [ListViewDTO]) { + self.views = views + } +} + +/// The single-view envelope shared by create (`201`), fork (`201`) and update +/// (`200`): `{"view":{…}}`. Matches the site-wide convention that +/// single-resource routes answer `{message?, }`. +public struct ListViewResponse: Codable, Sendable, Equatable { + public let view: ListViewDTO + /// Present on some writes; the live create/fork/update responses carried + /// only `view`. + public let message: String? + + public init(view: ListViewDTO, message: String? = nil) { + self.view = view + self.message = message + } +} + +// MARK: - Request bodies + +/// `POST /api/lists/{id}/views`. +/// +/// `scope` is required and validated — see `ListViewDTO.scope`. `config` and +/// `isDefault` are optional: omitting `config` produced the server default +/// `{"mode":"records","density":"comfortable","filters":[]}`. +public struct CreateListViewRequest: Codable, Sendable, Equatable { + public let name: String + public let scope: String + public let config: ListViewConfigDTO? + public let isDefault: Bool? + + public init( + name: String, + scope: String, + config: ListViewConfigDTO? = nil, + isDefault: Bool? = nil + ) { + self.name = name + self.scope = scope + self.config = config + self.isDefault = isDefault + } +} + +/// `PUT /api/lists/{id}/views/{viewId}`. +/// +/// - Important: **`config` is a whole-object REPLACE, not a merge.** A live +/// `PUT` that sent `config` without `density` reset the stored density from +/// `"compact"` back to `"comfortable"`. Callers must send the complete +/// config they want to end up with; the domain layer enforces this by only +/// ever writing a full `SavedListViewConfig`. +public struct UpdateListViewRequest: Codable, Sendable, Equatable { + public let name: String? + public let config: ListViewConfigDTO? + public let isDefault: Bool? + + public init( + name: String? = nil, + config: ListViewConfigDTO? = nil, + isDefault: Bool? = nil + ) { + self.name = name + self.config = config + self.isDefault = isDefault + } +} + +/// `POST /api/lists/{id}/views/{viewId}` — the fork body. +/// +/// The spec calls fork "the escape hatch": it copies a view into a personal +/// copy owned by the caller. VERIFIED live 2026-09-15 that it works on a +/// **personal** source view too, not only a shared one — so the affordance is +/// "duplicate this view for me", not strictly "take it off the owner". +public struct ForkListViewRequest: Codable, Sendable, Equatable { + /// The forked copy's name. Omitted, the server names it. + public let name: String? + + public init(name: String? = nil) { + self.name = name + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListViewsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListViewsEndpoint.swift new file mode 100644 index 0000000..cb46237 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListViewsEndpoint.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Request builders for **saved list views** — the five +/// `/api/lists/{id}/views*` routes (work-consolidation.md G40 / issue #81). +/// +/// Lives beside `ListsEndpoint.swift` rather than inside it: the five routes +/// are one coherent sub-resource with their own DTO family, and `Lists` is +/// already a 360-line namespace covering CRUD, schema, rows, watchers, sharing +/// and connections. Same `public enum Lists` namespace, so call sites read +/// `Lists.views(listId:)` alongside `Lists.rows(listId:)`. +/// +/// **Auth: `.bearer`, and no subscription gate.** All five are declared +/// `x-auth-type: sync-token` and `x-subscription-tier: free`, and all five +/// were reached live on 2026-09-15 with a Bearer token on a **free** account. +/// Do not add an entitlement check around them — a free user arranging their +/// own list is not a paid feature, and gating it would hide views the server +/// happily serves (the GitHub #40 rule: creation is gated, arrangement is not). +extension Lists { + + // MARK: - Saved views + + /// `GET /api/lists/[id]/views` → `{"views":[…]}`. + /// + /// Returns every **shared** view on the list plus the caller's **personal** + /// views, in one flat array. Unpaged — the route returns no pagination + /// envelope, so there is no `paginationKey` here. + /// + /// VERIFIED live: `200 {"views":[]}` on a list with no saved views + /// (re-read 2026-09-16), and a populated array carrying the eight-key row + /// documented on `ListViewDTO` (2026-09-15). + public static func views(listId: String) -> Request { + Request(method: .get, path: "/api/lists/\(listId)/views", auth: .bearer) + } + + /// `POST /api/lists/[id]/views` → `201 {"view":{…}}`. + /// + /// `scope` is the one validated field: an unknown token answers + /// `400 {"error":"scope must be \"personal\" or \"shared\"","code":"bad_request"}`. + /// Every `config` value, by contrast, defaults silently — see + /// `ListViewConfigDTO`. + public static func createView( + listId: String, + _ body: CreateListViewRequest + ) -> Request { + Request(method: .post, path: "/api/lists/\(listId)/views", body: .json(body), auth: .bearer) + } + + /// `POST /api/lists/[id]/views/[viewId]` → `201 {"view":{…}}` — fork. + /// + /// The **same verb and a deeper path** than create, which is the only thing + /// distinguishing the two: `POST …/views` creates, `POST …/views/{id}` + /// copies. Getting that backwards would silently create a blank view + /// instead of duplicating one, so the two builders are named for what they + /// do rather than for their verb. + public static func forkView( + listId: String, + viewId: String, + _ body: ForkListViewRequest + ) -> Request { + Request( + method: .post, + path: "/api/lists/\(listId)/views/\(viewId)", + body: .json(body), + auth: .bearer + ) + } + + /// `PUT /api/lists/[id]/views/[viewId]` → `200 {"view":{…}}`. + /// + /// - Important: the `config` it carries **replaces** the stored one whole; + /// see `UpdateListViewRequest`. + public static func updateView( + listId: String, + viewId: String, + _ body: UpdateListViewRequest + ) -> Request { + Request( + method: .put, + path: "/api/lists/\(listId)/views/\(viewId)", + body: .json(body), + auth: .bearer + ) + } + + /// `DELETE /api/lists/[id]/views/[viewId]` → `200 {"message":"View deleted"}`. + /// + /// Typed `EmptyResponse` because the body carries no view — the caller + /// already knows which id it removed, and decoding a confirmation string + /// would invite branching on server copy. + public static func deleteView(listId: String, viewId: String) -> Request { + Request(method: .delete, path: "/api/lists/\(listId)/views/\(viewId)", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListViewsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListViewsEndpointTests.swift new file mode 100644 index 0000000..1f6f25e --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListViewsEndpointTests.swift @@ -0,0 +1,420 @@ +import XCTest +@testable import InterlinedKit + +/// BDD coverage for the five saved-list-view routes (work-consolidation.md G40 +/// / issue #81). +/// +/// **Every JSON literal below is a payload the live API actually produced on +/// 2026-09-15**, not a shape derived from the OpenAPI document. That +/// distinction is the point of this suite: the spec is wrong about this +/// resource in two separate ways, and issue #81 asks specifically for a +/// contract test that fails on a renamed key instead of degrading to `nil`. +/// +/// • `createdAt` / `updatedAt` are REQUIRED in the spec and absent from every +/// live response — `test_givenLiveViewRowWithoutTimestamps_…` is the +/// regression guard. +/// • The spec's `config.filters` example was sent verbatim to the live API and +/// came back `[]` — `test_givenSpecExampleFilter_…` records that, so nobody +/// "fixes" the model back toward the document. +final class ListViewsEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + // One stub backs both transports so the decision-0001 401 safety net + // (which retries a Bearer 401 once over the session) reads from a + // single queue. + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: transport, + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + /// The exact live create/GET row, verbatim. Kept in one place so every test + /// below asserts against the same captured bytes. + private static let liveViewRow = """ + {"id":"v-1","listId":"33de2874-55cc-4b6e-be02-f6868a0cf0a9","userId":"c65", + "name":"Reading","scope":"personal", + "config":{"mode":"records","density":"comfortable","filters":[]}, + "isDefault":false,"position":0} + """ + + // MARK: - Builder shape + + func test_givenSavedViewBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + // All five are Bearer and unpaged — the collection route returns a bare + // `{views:[…]}` with no pagination envelope, so `paginationKey` must + // stay nil or `PaginatedDecoder` would be pointed at a key that is not + // there. + let list = Lists.views(listId: "L1") + XCTAssertEqual(list.method, .get) + XCTAssertEqual(list.path, "/api/lists/L1/views") + XCTAssertEqual(list.auth, .bearer) + XCTAssertNil(list.paginationKey) + + let create = Lists.createView(listId: "L1", CreateListViewRequest(name: "Reading", scope: "personal")) + XCTAssertEqual(create.method, .post) + XCTAssertEqual(create.path, "/api/lists/L1/views") + XCTAssertEqual(create.auth, .bearer) + XCTAssertNotNil(create.body) + + // Fork shares the verb with create and differs only by depth — assert + // the path so the two can never be transposed. + let fork = Lists.forkView(listId: "L1", viewId: "v-1", ForkListViewRequest(name: "Mine")) + XCTAssertEqual(fork.method, .post) + XCTAssertEqual(fork.path, "/api/lists/L1/views/v-1") + XCTAssertEqual(fork.auth, .bearer) + + let update = Lists.updateView(listId: "L1", viewId: "v-1", UpdateListViewRequest(name: "Renamed")) + XCTAssertEqual(update.method, .put) + XCTAssertEqual(update.path, "/api/lists/L1/views/v-1") + + let remove = Lists.deleteView(listId: "L1", viewId: "v-1") + XCTAssertEqual(remove.method, .delete) + XCTAssertEqual(remove.path, "/api/lists/L1/views/v-1") + XCTAssertEqual(remove.auth, .bearer) + } + + // MARK: - GET /views + + func test_givenLiveViewRowWithoutTimestamps_whenListingViews_thenDecodesEveryField() async throws { + // Given — two rows in the live eight-key shape: one shared, one + // personal-and-default. Neither carries createdAt/updatedAt, which the + // spec marks required; a schema-faithful decoder throws here. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"views":[ + {"id":"v-1","listId":"L1","userId":"c65","name":"Team board","scope":"shared", + "config":{"mode":"records","density":"compact","filters":[]}, + "isDefault":false,"position":0}, + {"id":"v-2","listId":"L1","userId":"c65","name":"Mine","scope":"personal", + "config":{"mode":"records","density":"comfortable","filters":[]}, + "isDefault":true,"position":0} + ]} + """, status: 200)) + + // When + let response = try await client.send(Lists.views(listId: "L1")) + + // Then + XCTAssertEqual(response.views.map(\.id), ["v-1", "v-2"]) + XCTAssertEqual(response.views.map(\.scope), ["shared", "personal"]) + XCTAssertEqual(response.views.map(\.isDefault), [false, true]) + XCTAssertEqual(response.views.map(\.config.density), ["compact", "comfortable"]) + XCTAssertEqual(response.views.first?.userId, "c65") + // `position` is per-scope-bucket, so both rows legitimately read 0. + XCTAssertEqual(response.views.map(\.position), [0, 0]) + XCTAssertNil(response.views.first?.createdAt) + XCTAssertNil(response.views.first?.updatedAt) + } + + func test_givenRenamedKey_whenListingViews_thenFailsTheDecodeRatherThanDegrading() async throws { + // Invalid input: `isDefault` arrives under a renamed key. Issue #81's + // acceptance criterion is that this FAILS rather than quietly reading + // as `nil`/`false` — the G21 and G25 defect mode. `isDefault` is the + // sharpest case: silently false means the user's chosen default view + // stops opening and nothing anywhere reports an error. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"views":[{"id":"v-1","listId":"L1","userId":"c65","name":"Reading","scope":"personal", + "config":{"mode":"records","density":"comfortable","filters":[]}, + "default":false,"position":0}]} + """, status: 200)) + + do { + _ = try await client.send(Lists.views(listId: "L1")) + XCTFail("Expected a decode failure on the renamed isDefault key") + } catch let error as APIError { + guard case .decoding = error else { + return XCTFail("Expected APIError.decoding, got \(error)") + } + } + } + + func test_givenListWithNoSavedViews_whenListingViews_thenDecodesEmptyCollection() async throws { + // Boundary — the literal live body re-confirmed by GET on 2026-09-16. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"views":[]}"#, status: 200)) + + let response = try await client.send(Lists.views(listId: "L1")) + + XCTAssertTrue(response.views.isEmpty) + } + + func test_givenUnauthorizedCaller_whenListingViews_thenSurfacesUnauthorized() async throws { + // Upstream failure. Two enqueues: the 401 safety net retries once over + // the session transport before surfacing the status. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Unauthorized"}"#, status: 401)) + await transport.enqueue(.json(#"{"error":"Unauthorized"}"#, status: 401)) + + do { + _ = try await client.send(Lists.views(listId: "L1")) + XCTFail("Expected APIError.unauthorized") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 401) + } + } + + // MARK: - POST /views (create) + + func test_givenNamedPersonalView_whenCreating_thenSendsConfigAsObjectAndDecodesEnvelope() async throws { + // The single most important assertion in this file: `config` goes out + // as a JSON **object**. The OpenAPI request body declares it + // `{"type":"string"}` — issue #81 was filed on that reading — and a + // live POST with an object returned 201. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"view":\#(Self.liveViewRow)}"#, status: 201)) + + let response = try await client.send( + Lists.createView( + listId: "L1", + CreateListViewRequest( + name: "Reading", + scope: "personal", + config: ListViewConfigDTO(mode: "records", density: "comfortable"), + isDefault: false + ) + ) + ) + + XCTAssertEqual(response.view.id, "v-1") + XCTAssertEqual(response.view.scope, "personal") + XCTAssertEqual(response.view.config.mode, "records") + + let sent = await transport.received + XCTAssertEqual(sent.last?.httpMethod, "POST") + XCTAssertEqual(sent.last?.url?.path, "/api/lists/L1/views") + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["name"] as? String, "Reading") + XCTAssertEqual(json["scope"] as? String, "personal") + XCTAssertTrue(json["config"] is [String: Any], "config must be sent as an object, not a string") + let config = try XCTUnwrap(json["config"] as? [String: Any]) + XCTAssertEqual(config["mode"] as? String, "records") + XCTAssertEqual(config["density"] as? String, "comfortable") + } + + func test_givenSpecExampleFilter_whenCreating_thenRoundTripsWhateverTheServerStored() async throws { + // The spec's own response example shows this filter surviving. It does + // not: the live API answered `"filters": []` to exactly this body. The + // client must therefore send the filter opaquely and believe the + // response, never its own optimistic copy. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"view":\#(Self.liveViewRow)}"#, status: 201)) + + _ = try await client.send( + Lists.createView( + listId: "L1", + CreateListViewRequest( + name: "Unread", + scope: "personal", + config: ListViewConfigDTO( + mode: "records", + density: "comfortable", + filters: [.object(["key": .string("read"), "op": .string("eq"), "value": .bool(false)])] + ) + ) + ) + ) + + let sent = await transport.received + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let config = try XCTUnwrap(json["config"] as? [String: Any]) + let filters = try XCTUnwrap(config["filters"] as? [[String: Any]]) + XCTAssertEqual(filters.first?["key"] as? String, "read") + XCTAssertEqual(filters.first?["op"] as? String, "eq") + XCTAssertEqual(filters.first?["value"] as? Bool, false) + } + + func test_givenOmittedOptionalFields_whenCreating_thenSkipsThemInTheBody() async throws { + // Boundary: omitting `config` is legal and makes the server apply its + // default. Sending `"config": null` is not the same thing, so assert + // the keys are genuinely absent. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"view":\#(Self.liveViewRow)}"#, status: 201)) + + _ = try await client.send( + Lists.createView(listId: "L1", CreateListViewRequest(name: "Plain", scope: "shared")) + ) + + let sent = await transport.received + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertNil(json["config"]) + XCTAssertNil(json["isDefault"]) + XCTAssertEqual(json.keys.sorted(), ["name", "scope"]) + } + + func test_givenIllegalScope_whenCreating_thenSurfacesTheServersBadRequest() async throws { + // Upstream failure, and the asymmetry worth encoding: `scope` is the + // one field the server validates. Verbatim live body. + let (client, transport) = makeClient() + await transport.enqueue(.json( + #"{"error":"scope must be \"personal\" or \"shared\"","code":"bad_request"}"#, + status: 400 + )) + + do { + _ = try await client.send( + Lists.createView(listId: "L1", CreateListViewRequest(name: "X", scope: "bogus_scope")) + ) + XCTFail("Expected APIError.badRequest") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 400) + guard case .badRequest(let message) = error else { + return XCTFail("Expected APIError.badRequest, got \(error)") + } + XCTAssertEqual(message, #"scope must be "personal" or "shared""#) + } + } + + // MARK: - POST /views/{id} (fork) + + func test_givenSharedView_whenForking_thenPostsToTheViewPathAndDecodesTheCopy() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"view":{"id":"v-9","listId":"L1","userId":"c65","name":"My copy","scope":"personal", + "config":{"mode":"records","density":"compact","filters":[]}, + "isDefault":false,"position":1}} + """, status: 201)) + + let response = try await client.send( + Lists.forkView(listId: "L1", viewId: "v-1", ForkListViewRequest(name: "My copy")) + ) + + // A fork always lands in the caller's personal bucket — that is the + // whole point of the "escape hatch". + XCTAssertEqual(response.view.id, "v-9") + XCTAssertEqual(response.view.scope, "personal") + XCTAssertEqual(response.view.config.density, "compact") + let sent = await transport.received + XCTAssertEqual(sent.last?.httpMethod, "POST") + XCTAssertEqual(sent.last?.url?.path, "/api/lists/L1/views/v-1") + } + + func test_givenNoName_whenForking_thenSendsAnEmptyBodyAndLetsTheServerName() async throws { + // Boundary: the name is optional on fork. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"view":\#(Self.liveViewRow)}"#, status: 201)) + + _ = try await client.send(Lists.forkView(listId: "L1", viewId: "v-1", ForkListViewRequest())) + + let sent = await transport.received + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertTrue(json.isEmpty) + } + + func test_givenMissingSourceView_whenForking_thenSurfacesNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"View not found","code":"not_found"}"#, status: 404)) + + do { + _ = try await client.send(Lists.forkView(listId: "L1", viewId: "nope", ForkListViewRequest())) + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 404) + } + } + + // MARK: - PUT /views/{id} + + func test_givenFullConfig_whenUpdating_thenSendsEveryConfigKeyBecausePutReplaces() async throws { + // PUT replaces the config object whole — a live PUT that omitted + // `density` reset it from "compact" to "comfortable". So the request + // must carry every key the caller wants to keep. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"view":{"id":"v-1","listId":"L1","userId":"c65","name":"Reading","scope":"personal", + "config":{"mode":"records","density":"compact","filters":[]}, + "isDefault":true,"position":0}} + """, status: 200)) + + let response = try await client.send( + Lists.updateView( + listId: "L1", + viewId: "v-1", + UpdateListViewRequest( + name: "Reading", + config: ListViewConfigDTO(mode: "records", density: "compact"), + isDefault: true + ) + ) + ) + + XCTAssertEqual(response.view.config.density, "compact") + XCTAssertTrue(response.view.isDefault) + let sent = await transport.received + XCTAssertEqual(sent.last?.httpMethod, "PUT") + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let config = try XCTUnwrap(json["config"] as? [String: Any]) + XCTAssertEqual(config["mode"] as? String, "records") + XCTAssertEqual(config["density"] as? String, "compact") + XCTAssertNotNil(config["filters"]) + } + + func test_givenRenameOnly_whenUpdating_thenOmitsConfigEntirely() async throws { + // Boundary: a pure rename must NOT send a partial config — an + // incomplete object would replace the stored one and silently reset + // whatever it left out. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"view":\#(Self.liveViewRow)}"#, status: 200)) + + _ = try await client.send( + Lists.updateView(listId: "L1", viewId: "v-1", UpdateListViewRequest(name: "Renamed")) + ) + + let sent = await transport.received + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json.keys.sorted(), ["name"]) + } + + func test_givenForbiddenUpdate_whenUpdatingSomeoneElsesView_thenSurfacesForbidden() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Forbidden"}"#, status: 403)) + + do { + _ = try await client.send( + Lists.updateView(listId: "L1", viewId: "v-1", UpdateListViewRequest(name: "X")) + ) + XCTFail("Expected APIError.forbidden") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 403) + } + } + + // MARK: - DELETE /views/{id} + + func test_givenExistingView_whenDeleting_thenSendsDeleteAndIgnoresTheMessageBody() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"message":"View deleted"}"#, status: 200)) + + try await client.sendVoid(Lists.deleteView(listId: "L1", viewId: "v-1")) + + let sent = await transport.received + XCTAssertEqual(sent.last?.httpMethod, "DELETE") + XCTAssertEqual(sent.last?.url?.path, "/api/lists/L1/views/v-1") + } + + func test_givenMissingView_whenDeleting_thenSurfacesNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"View not found","code":"not_found"}"#, status: 404)) + + do { + try await client.sendVoid(Lists.deleteView(listId: "L1", viewId: "nope")) + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 404) + } + } +}