From e2158e737d2117db4038ddcf216a76d384d1aaff Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:06:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(lists):=20saved=20list=20views=20=E2=80=94?= =?UTF-8?q?=20shared/personal,=20forking,=20per-user=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS shipped one hard-coded arrangement per list. On a shared list that meant a collaborator could not see the owner's arrangement at all, and had no way to keep one of their own — the five /api/lists/{id}/views routes have existed the whole time and no prior parity sweep noticed them. This lands the full surface: Kit builders and DTOs, domain models and the five ListsServicing methods, and a saved-views menu plus manage sheet on the rows pane. Shared and personal views get separate sections and glyphs, "Duplicate" (the spec's escape hatch) sits at the top level rather than buried in the sheet, and load() applies the caller's isDefault view so two people open the same shared list into their own arrangements. Everything is modelled against payloads captured live on 2026-09-15, not against the OpenAPI document, because the document is wrong about this resource twice over: * It marks ListView.createdAt/updatedAt REQUIRED and the API sends neither, on POST, GET or PUT. A schema-faithful decoder would have thrown on every row. Both are optional here. * Its response example shows config.filters surviving a write. That exact filter object was sent live and came back []. No fixture in this change is derived from the example. It also declares the request body's config as {"type":"string"}, which is a generator artifact — issue #81 was filed on that reading. config is an object, and the server whitelists exactly four keys (mode, density, filters, search), silently stripping columns/visibleColumns/columnOrder/ hiddenColumns/groupBy/sort/sortBy/sortDirection/rowHeight. So config does NOT carry column order, visibility or sort, and this change does not pretend otherwise: density is the only stored value with a visible client effect today, and it is what applying a view changes. The validation asymmetry is encoded deliberately. scope hard-fails (400 "scope must be \"personal\" or \"shared\""), so it is a closed enum with no round-trip escape hatch — preserving an unknown token would guarantee a 400 on the next write. mode and density default silently, so both are unknown-tolerant: a value this build does not recognise is a real state, and collapsing it would overwrite the user's stored choice on save. The filters element grammar is UNCONFIRMED and is named as such in the code. The recon account's only list has an empty schema, so every probe filter referenced a column that does not exist and was dropped, leaving a grammar failure indistinguishable from a column-not-found. filters is therefore carried as opaque JSON that round-trips untouched, and every write sends the complete config because PUT replaces rather than merges — a live PUT omitting density reset it from compact to comfortable. Also moves ListJSONValue.init(from: ListCellValue) out of ListsService into ListMappers beside its inverse: G40 is the second writer of loose JSON, and a drifted private copy of a lossless round-trip would silently corrupt whatever it disagreed about. Refs #81 Co-Authored-By: Claude Opus 5 --- App/Composition/ListsEventBus.swift | 9 + App/Features/Lists/ListRowsView.swift | 51 +- App/Features/Lists/OwnedListsViewModel.swift | 5 +- App/Features/Lists/SavedViewsControl.swift | 373 ++++++++++++ App/Features/Lists/SavedViewsViewModel.swift | 379 ++++++++++++ .../Lists/WatchedListsViewModel.swift | 2 +- AppTests/SavedViewsViewModelTests.swift | 552 ++++++++++++++++++ AppTests/Support/StubListsService.swift | 110 ++++ .../InterlinedDomain/Models/ListMappers.swift | 28 + .../Models/SavedListView.swift | 272 +++++++++ .../Services/ListsService.swift | 170 +++++- .../SavedListViewsServiceTests.swift | 467 +++++++++++++++ .../Support/StubAPIClient.swift | 35 ++ .../InterlinedKit/DTOs/ListViewDTO.swift | 252 ++++++++ .../Endpoints/ListViewsEndpoint.swift | 93 +++ .../ListViewsEndpointTests.swift | 420 +++++++++++++ 16 files changed, 3190 insertions(+), 28 deletions(-) create mode 100644 App/Features/Lists/SavedViewsControl.swift create mode 100644 App/Features/Lists/SavedViewsViewModel.swift create mode 100644 AppTests/SavedViewsViewModelTests.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SavedListView.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/SavedListViewsServiceTests.swift create mode 100644 Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListViewDTO.swift create mode 100644 Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListViewsEndpoint.swift create mode 100644 Packages/InterlinedKit/Tests/InterlinedKitTests/ListViewsEndpointTests.swift diff --git a/App/Composition/ListsEventBus.swift b/App/Composition/ListsEventBus.swift index 02af9bc..e15bfcd 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 2900c19..76c28cc 100644 --- a/App/Features/Lists/ListRowsView.swift +++ b/App/Features/Lists/ListRowsView.swift @@ -29,9 +29,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 @@ -128,6 +168,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( @@ -159,7 +206,7 @@ struct ListRowsView: View { TableColumnForEach(columns, id: \.self) { column in TableColumn(column) { (row: ListRow) in Text(row.fields[column]?.displayText ?? "") - .lineLimit(2) + .lineLimit(cellLineLimit) } } } @@ -342,7 +389,7 @@ struct ListRowsView: View { .foregroundStyle(.secondary) Text(row.fields[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 969acf6..178654a 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -40,6 +40,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 } @@ -73,6 +79,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 @@ -156,6 +175,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 { @@ -310,6 +340,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 { @@ -459,4 +535,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 4ef5cb7..5e34a18 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift @@ -106,3 +106,31 @@ extension RowsPage { ) } } + +// 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 whenever the client writes loose JSON (row cells, and the saved-view +/// `config.filters` whose element grammar is unconfirmed). +/// +/// Moved here from `ListsService.swift` (where it was `fileprivate`) when G40 +/// added a second writer: 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 { + case .null: self = .null + case .bool(let v): self = .bool(v) + case .int(let v): self = .int(v) + case .double(let v): self = .double(v) + case .string(let v): self = .string(v) + case .array(let items): + self = .array(items.map(ListJSONValue.init(from:))) + case .object(let dict): + self = .object(dict.mapValues(ListJSONValue.init(from:))) + } + } +} 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 f3489e7..aa8e9c3 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -28,6 +28,13 @@ public enum ListsError: Error, Sendable, Equatable { /// the route treats a missing `userId` as "subscribe *me* to this list", /// 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 } extension ListsError: LocalizedError, CustomStringConvertible { @@ -41,6 +48,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." } } } @@ -236,6 +245,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 @@ -630,6 +692,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. @@ -650,6 +785,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. @@ -661,26 +808,3 @@ public final class ListsService: ListsServicing { } } } - -// MARK: - Wire projection helper - -/// Recursive projection from the domain's loose `ListCellValue` back to the -/// kit's `ListJSONValue` — used when writing rows. The two enums are -/// structurally identical (M1 chose to project the wire union into a domain -/// equivalent so view code never sees `ListJSONValue`); this is the inverse -/// of the `init(from value:)` already in `ListMappers.swift`. -extension ListJSONValue { - fileprivate init(from value: ListCellValue) { - switch value { - case .null: self = .null - case .bool(let v): self = .bool(v) - case .int(let v): self = .int(v) - case .double(let v): self = .double(v) - case .string(let v): self = .string(v) - case .array(let items): - self = .array(items.map(ListJSONValue.init(from:))) - case .object(let dict): - self = .object(dict.mapValues(ListJSONValue.init(from:))) - } - } -} 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 a0d042f..56db1f8 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 @@ -31,6 +32,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 @@ -90,5 +102,28 @@ actor StubAPIClient: APIClientProtocol { recorded.append( RecordedRequest(method: request.method.rawValue, path: request.path, query: query) ) + 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) + } + } +}