diff --git a/App/Features/Lists/AddWatcherSheetView.swift b/App/Features/Lists/AddWatcherSheetView.swift index 72fd18a..41d087e 100644 --- a/App/Features/Lists/AddWatcherSheetView.swift +++ b/App/Features/Lists/AddWatcherSheetView.swift @@ -1,8 +1,15 @@ // AddWatcherSheetView // -// Sheet for inviting a user to watch a list by handle (NW-1). Calls -// WatchersViewModel.lookupAndAdd to resolve the handle, then -// WatchersViewModel.addWatcher on confirm. +// Sheet for granting someone access to a list (work-consolidation.md G23, +// issue #48). Searches candidates through `GET /api/lists/{id}/watchers/users` +// — the route built for this, which auto-excludes people who already have +// access — and grants the chosen person access through the real +// `POST /api/lists/{id}/watchers`. +// +// Granting access to a named user is subscriber-gated server-side, so a free +// owner's 403 arrives as `ListsError.subscriberRequired` and is rendered as an +// upsell rather than an error banner (same treatment as Share Links and +// Invite by Email). // // Per Decision 0003 the view imports only InterlinedDomain. @@ -14,85 +21,24 @@ struct AddWatcherSheetView: View { @Bindable var viewModel: WatchersViewModel @Environment(\.dismiss) private var dismiss - @State private var handleInput: String = "" + @State private var searchText: String = "" @State private var selectedRole: WatcherRole = .viewer var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Add Watcher") + Text("Share This List") .font(.ilTitle(18)) .padding(.top, 4) - VStack(alignment: .leading, spacing: 8) { - Text("Handle") - .font(.ilMono(10)) - .foregroundStyle(.secondary) - HStack { - TextField("@username", text: $handleInput) - .textFieldStyle(.roundedBorder) - Button("Search") { - Task { await viewModel.lookupAndAdd(handle: handleInput) } - } - .buttonStyle(.bordered) - .disabled(handleInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || viewModel.isLookingUp) - } - if viewModel.isLookingUp { - ProgressView() - .controlSize(.small) - } - } + searchField - if let found = viewModel.foundUser { - Divider() - VStack(alignment: .leading, spacing: 6) { - Text("Found user") - .font(.ilMono(10)) - .foregroundStyle(.secondary) - HStack(spacing: 8) { - Image(systemName: "person.crop.circle") - .font(.ilBody()) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 1) { - Text(found.displayName ?? found.username) - .font(.ilBody()) - .fontWeight(.medium) - Text("@\(found.username)") - .font(.ilMono(10)) - .foregroundStyle(.secondary) - } - Spacer() - } + if viewModel.showSubscriberUpsell { + subscriberUpsell + } - HStack { - Text("Role") - .font(.ilMono(10)) - .foregroundStyle(.secondary) - Picker("Role", selection: $selectedRole) { - Text("Viewer").tag(WatcherRole.viewer) - Text("Editor").tag(WatcherRole.editor) - Text("Owner").tag(WatcherRole.owner) - } - .pickerStyle(.menu) - .frame(width: 120) - Spacer() - } - } + rolePicker - HStack { - Spacer() - Button("Add") { - Task { - await viewModel.addWatcher(userId: found.id, role: selectedRole) - if viewModel.error == nil { - dismiss() - } - } - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.pendingOperations.contains(found.id)) - } - } + candidateList if let error = viewModel.error { Text(error.localizedDescription) @@ -100,15 +46,127 @@ struct AddWatcherSheetView: View { .foregroundStyle(Color.accentColor) } - Spacer() - HStack { Spacer() - Button("Cancel") { dismiss() } + Button("Done") { dismiss() } .keyboardShortcut(.cancelAction) } } .padding(20) - .frame(minWidth: 380, minHeight: 260) + .frame(minWidth: 420, minHeight: 380) + .task { + // Open on the unfiltered first page so the sheet is useful before + // the user types anything. + if !viewModel.hasSearchedOnce { + await viewModel.searchCandidates(query: "") + } + } + } + + // MARK: - Sections + + private var searchField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Find someone") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + HStack { + TextField("Name or username", text: $searchText) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await viewModel.searchCandidates(query: searchText) } + } + Button("Search") { + Task { await viewModel.searchCandidates(query: searchText) } + } + .buttonStyle(.bordered) + .disabled(viewModel.isSearching) + } + if viewModel.isSearching { + ProgressView() + .controlSize(.small) + } + } + } + + private var rolePicker: some View { + HStack { + Text("Access") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Picker("Access", selection: $selectedRole) { + ForEach(WatcherRole.allCases, id: \.self) { role in + Text(role.label).tag(role) + } + } + .pickerStyle(.menu) + .frame(width: 140) + Spacer() + } + } + + @ViewBuilder + private var candidateList: some View { + if viewModel.candidates.isEmpty { + Text(viewModel.hasSearchedOnce + ? "Nobody left to add — everyone matching already has access." + : "Search for someone to give access to.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + List(viewModel.candidates) { candidate in + candidateRow(candidate) + } + .frame(maxHeight: .infinity) + } + } + + private func candidateRow(_ candidate: CollaboratorCandidate) -> some View { + HStack(spacing: 8) { + Image(systemName: "person.crop.circle") + .font(.ilBody()) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(candidate.displayLabel) + .font(.ilBody()) + .fontWeight(.medium) + if let username = candidate.username, !username.isEmpty { + Text("@\(username)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Add") { + Task { await viewModel.addWatcher(candidate: candidate, role: selectedRole) } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.pendingOperations.contains(candidate.id)) + .accessibilityLabel("Give \(candidate.displayLabel) \(selectedRole.label) access") + } + .padding(.vertical, 2) + } + + private var subscriberUpsell: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "star.circle.fill") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text("Sharing a list with someone is a subscriber feature") + .font(.ilBody().weight(.semibold)) + Text("Your subscription does not cover granting access. Removing access always works.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Dismiss") { viewModel.dismissSubscriberUpsell() } + .buttonStyle(.bordered) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) } } diff --git a/App/Features/Lists/ContributorsView.swift b/App/Features/Lists/ContributorsView.swift new file mode 100644 index 0000000..4ff2bd1 --- /dev/null +++ b/App/Features/Lists/ContributorsView.swift @@ -0,0 +1,152 @@ +// ContributorsView +// +// The ranked contributor panel for a list (work-consolidation.md G23 / +// issue #48), presented as a sheet from the Lists toolbar. Shows who added +// and edited rows, in the server's ranking order. +// +// Per Decision 0003 the view imports only InterlinedDomain. + +import SwiftUI +import InterlinedDomain + +struct ContributorsView: View { + + let listId: String + let environment: AppEnvironment + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: ContributorsViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Loading contributors") + .padding() + } + } + .frame(minWidth: 440, minHeight: 340) + .task { + if viewModel == nil { + let model = ContributorsViewModel(lists: environment.lists, listId: listId) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func content(viewModel: ContributorsViewModel) -> some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + + if viewModel.isLoading, viewModel.contributors.isEmpty { + ProgressView() + .accessibilityLabel("Loading contributors") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.error, viewModel.contributors.isEmpty { + errorState(error: error, viewModel: viewModel) + } else if viewModel.contributors.isEmpty { + Text("No contributions yet — nobody has added or edited a row.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + List { + ForEach(Array(viewModel.contributors.enumerated()), id: \.element.id) { index, contributor in + row(rank: index + 1, contributor: contributor, total: viewModel.totalScore) + } + } + } + + Divider() + footer + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Contributors") + .font(.ilTitle(20)) + Text("Everyone who has added or edited rows on this list, most active first.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + + private func row(rank: Int, contributor: ListContributor, total: Int) -> some View { + HStack(spacing: 10) { + Text("\(rank)") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .trailing) + .accessibilityHidden(true) + + Image(systemName: "person.crop.circle") + .font(.ilMono(24)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(contributor.displayLabel) + .font(.ilBody()) + if let username = contributor.username, !username.isEmpty { + Text("@\(username)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("\(contributor.addedCount) added · \(contributor.editedCount) edited") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + // Guarded: `total` is 0 only when the list has no + // contributors, in which case this row does not render. + if total > 0 { + Text("\(Int((Double(contributor.score) / Double(total) * 100).rounded()))% of edits") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(contributor.displayLabel), rank \(rank), \(contributor.addedCount) rows added, \(contributor.editedCount) rows edited" + ) + } + + private func errorState(error: Error, viewModel: ContributorsViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label("Couldn't load contributors", systemImage: "exclamationmark.triangle") + .font(.ilBody().weight(.semibold)) + Text(error.localizedDescription) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { await viewModel.load() } + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(16) + } + + private var footer: some View { + HStack { + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } +} diff --git a/App/Features/Lists/ContributorsViewModel.swift b/App/Features/Lists/ContributorsViewModel.swift new file mode 100644 index 0000000..07d6c66 --- /dev/null +++ b/App/Features/Lists/ContributorsViewModel.swift @@ -0,0 +1,63 @@ +// ContributorsViewModel +// +// Drives `ContributorsView` — the ranked "who built this list" panel behind +// `GET /api/lists/{id}/contributors` (work-consolidation.md G23 / issue #48). +// +// The route is documented as unpaged ("no server paging") and returns the +// contributors already ranked, so this view model deliberately does no +// sorting of its own: the server owns the ranking formula and a future change +// to it should not need a client release. +// +// Reads through `ListsServicing` only. Per decision 0003 it consumes only +// `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ContributorsViewModel { + + private let lists: ListsServicing + let listId: String + + /// Contributors in the server's ranking order. + private(set) var contributors: [ListContributor] = [] + + /// True while the load round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load. + private(set) var error: Error? + + /// True once the first load resolved, so the view can tell "loading" from + /// "nobody has contributed yet". + private(set) var hasLoadedOnce: Bool = false + + init(lists: ListsServicing, listId: String) { + self.lists = lists + self.listId = listId + } + + /// Total edits across everyone — the denominator the rows show their + /// share against. `0` when the list has no contributors, which the view + /// guards on before dividing. + var totalScore: Int { + contributors.reduce(0) { $0 + $1.score } + } + + func load() async { + isLoading = true + error = nil + defer { + isLoading = false + hasLoadedOnce = true + } + do { + contributors = try await lists.contributors(of: listId) + } catch { + self.error = error + } + } +} diff --git a/App/Features/Lists/ListRowsView.swift b/App/Features/Lists/ListRowsView.swift index 65bbc4b..2900c19 100644 --- a/App/Features/Lists/ListRowsView.swift +++ b/App/Features/Lists/ListRowsView.swift @@ -11,6 +11,12 @@ struct ListRowsView: View { let list: OwnedList let viewModel: ListRowsViewModel + /// `true` when the signed-in account may read this list but not change it — + /// a `watcher`-role share (work-consolidation.md G23). Row-mutating + /// affordances are *hidden*, not disabled, per the project's + /// "never enabled-but-broken" rule. Defaults to `false` so every existing + /// owned-list call site is unchanged. + var isReadOnly: Bool = false @Environment(\.appEnvironment) private var environment @State private var selection: Set = [] @@ -82,7 +88,12 @@ struct ListRowsView: View { // action becomes "New Issue" (opening the issue composer/browser) // rather than a native empty-row create, which wouldn't survive the // next sync. Detection is row-derived (`viewModel.isGitHubBacked`). - if viewModel.isGitHubBacked { + if isReadOnly { + Label("Read-only", systemImage: "eye") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .help("This list was shared with you for viewing — you can't change its rows") + } else if viewModel.isGitHubBacked { Button { showsIssues = true } label: { @@ -97,12 +108,14 @@ struct ListRowsView: View { } } - Button { - deletePending = true - } label: { - Label("Delete", systemImage: "minus") + if !isReadOnly { + Button { + deletePending = true + } label: { + Label("Delete", systemImage: "minus") + } + .disabled(selection.isEmpty || viewModel.isGitHubBacked) } - .disabled(selection.isEmpty || viewModel.isGitHubBacked) // "Create from…" over the current row selection // (work-consolidation.md G16). Unlike Delete this is safe on a diff --git a/App/Features/Lists/OwnedListsRootView.swift b/App/Features/Lists/OwnedListsRootView.swift index 45e60ae..2dbeae0 100644 --- a/App/Features/Lists/OwnedListsRootView.swift +++ b/App/Features/Lists/OwnedListsRootView.swift @@ -23,6 +23,11 @@ struct OwnedListsRootView: View { @Environment(\.appEnvironment) private var environment @State private var viewModel: OwnedListsViewModel? + /// Drives the "Shared with me" sidebar section — lists other people own + /// and gave this account access to (work-consolidation.md G23 / issue #48). + /// A separate view model so a failing `GET /api/lists/watching` scopes its + /// error to that section and leaves the owned lists rendering. + @State private var watchedViewModel: WatchedListsViewModel? @State private var rowsViewModel: ListRowsViewModel? @State private var showsNewListSheet: Bool = false /// Drives the "Draft with AI" sheet (work-consolidation.md G15). @@ -37,6 +42,7 @@ struct OwnedListsRootView: View { @State private var showsInvites: Bool = false @State private var showsVisibility: Bool = false @State private var showsConnections: Bool = false + @State private var showsContributors: Bool = false @State private var showsIssues: Bool = false @State private var listIDPendingDelete: String? @@ -67,6 +73,18 @@ struct OwnedListsRootView: View { // the TTL we trust the cache and skip the network. await model.refresh() } + + // "Shared with me" loads independently of the owned lists above, + // and on its own TTL, so neither section can block or blank the + // other (issue #48 acceptance criteria). + if watchedViewModel == nil { + let watched = WatchedListsViewModel(lists: environment.lists) + watchedViewModel = watched + await watched.load() + await subscribeWatchedEventBus(viewModel: watched, bus: environment.listsEventBus) + } else if let watched = watchedViewModel, watched.shouldRefresh { + await watched.load() + } } .task(id: viewModel?.selectedListID) { rowsViewModel = nil @@ -101,8 +119,15 @@ struct OwnedListsRootView: View { .frame(minWidth: 220, idealWidth: 260) Group { + // The sidebar's single selection spans both sections, so + // the rows pane resolves it against the owned lists first + // and the shared-with-me lists second. if let selected = viewModel.selectedList, let rowsVM = rowsViewModel { ListRowsView(list: selected, viewModel: rowsVM) + } else if let watched = selectedWatchedList(viewModel: viewModel), let rowsVM = rowsViewModel { + // A `watcher`-role share is read-only: hide the row + // write affordances rather than letting them 403. + ListRowsView(list: watched.list, viewModel: rowsVM, isReadOnly: !watched.canEdit) } else { placeholderSelectListState } @@ -167,27 +192,42 @@ struct OwnedListsRootView: View { ? "Refresh from GitHub source" : "Refresh lists") + // Owner-only actions gate on an *owned* selection, not merely + // on "something is selected": every route behind them is + // owner-only server-side, so leaving them live for a list + // someone shared with you renders an enabled-but-broken + // control (work-consolidation.md G23). Button { showsSchemaEditor = true } label: { Label("Edit Schema", systemImage: "tablecells") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) Button { showsWatchers = true } label: { Label("Watchers", systemImage: "person.2") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) .help("Manage who can see and edit this list") + // Contributors is readable by anyone with access, so it stays + // enabled for a shared list too. + Button { + showsContributors = true + } label: { + Label("Contributors", systemImage: "person.3") + } + .disabled(viewModel.selectedListID == nil) + .help("See who has added and edited rows on this list") + Button { showsShareLinks = true } label: { Label("Share Links", systemImage: "link.badge.plus") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) .help("Create and manage shareable links for this list") Button { @@ -195,7 +235,7 @@ struct OwnedListsRootView: View { } label: { Label("Invite by Email", systemImage: "envelope") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) .help("Invite people to this list by email") Button { @@ -203,7 +243,7 @@ struct OwnedListsRootView: View { } label: { Label("Make Public", systemImage: "globe") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) .help("Control whether anyone with the link can view this list") Button { @@ -211,7 +251,7 @@ struct OwnedListsRootView: View { } label: { Label("Connections", systemImage: "point.3.connected.trianglepath.dotted") } - .disabled(viewModel.selectedListID == nil) + .disabled(viewModel.selectedList == nil) Button { showsIssues = true @@ -264,6 +304,11 @@ struct OwnedListsRootView: View { WatchersView(listId: listId, environment: environment) } } + .sheet(isPresented: $showsContributors) { + if let environment, let listId = viewModel.selectedListID { + ContributorsView(listId: listId, environment: environment) + } + } .sheet(isPresented: $showsShareLinks) { if let environment, let listId = viewModel.selectedListID { ShareLinksView(target: .list(id: listId), environment: environment) @@ -357,13 +402,72 @@ struct OwnedListsRootView: View { } } } // Section("Lists") + + sharedWithMeSection } .listStyle(.sidebar) .refreshable { await viewModel.refresh() + await watchedViewModel?.load() + } + } + + /// The "Shared with me" sidebar section — lists other people own and gave + /// this account access to (work-consolidation.md G23 / issue #48). Mirrors + /// the web's `/lists` datagrid columns: title, owner, and your role. + /// + /// The section is omitted entirely when nothing is shared and nothing went + /// wrong, so an account nobody shares with sees the sidebar it always saw. + @ViewBuilder + private var sharedWithMeSection: some View { + if let watched = watchedViewModel { + if let error = watched.error, watched.watched.isEmpty { + Section("Shared with me") { + VStack(alignment: .leading, spacing: 6) { + Label("Couldn't load shared lists", systemImage: "exclamationmark.triangle") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.secondary) + Button("Retry") { + Task { await watched.load() } + } + .font(.caption) + } + .padding(.vertical, 4) + } + } else if watched.watched.isEmpty { + // Nothing shared and no error: render nothing rather than an + // empty-state row that would sit there forever. + EmptyView() + } else { + // Grouped by the caller's role so "what I can edit" reads + // apart from "what I can only look at". + ForEach(watched.groupedByRole, id: \.role) { group in + Section("Shared with me — \(group.role.label)") { + ForEach(group.lists) { entry in + WatchedListSidebarRow(entry: entry) + .tag(entry.id) + } + } + } + if watched.hasMore { + Button("Load more shared lists") { + Task { await watched.loadMore() } + } + .font(.caption) + } + } } } + /// Resolves the sidebar selection against the shared-with-me lists. Only + /// consulted after the owned lists miss, so an id can never resolve twice. + private func selectedWatchedList(viewModel: OwnedListsViewModel) -> WatchedList? { + watchedViewModel?.list(withID: viewModel.selectedListID) + } + private var placeholderSelectListState: some View { VStack(spacing: 8) { Image(systemName: "list.bullet.rectangle") @@ -402,6 +506,18 @@ struct OwnedListsRootView: View { } } + private func subscribeWatchedEventBus( + viewModel: WatchedListsViewModel, + bus: ListsEventBus + ) async { + Task { [weak viewModel] in + for await event in bus.events() { + guard let viewModel else { return } + viewModel.apply(event: event) + } + } + } + private func subscribeRowsEventBus( viewModel: ListRowsViewModel, bus: ListsEventBus @@ -415,6 +531,49 @@ struct OwnedListsRootView: View { } } +// MARK: - Shared-with-me sidebar row + +/// One row in the "Shared with me" section: the list title, who owns it, and +/// the caller's role — the same three columns the web's `/lists` datagrid +/// shows (work-consolidation.md G23). +private struct WatchedListSidebarRow: View { + let entry: WatchedList + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "person.crop.rectangle.stack") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(entry.title) + .lineLimit(1) + HStack(spacing: 4) { + if let owner = entry.owner { + Text(owner.displayLabel) + .lineLimit(1) + } + // The parent projection is a label only — a shared child + // does not imply access to its parent, so it never links. + if let parentTitle = entry.parentTitle { + Text("· in \(parentTitle)") + .lineLimit(1) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + let owner = entry.owner.map { ", owned by \($0.displayLabel)" } ?? "" + return "\(entry.title)\(owner), your access: \(entry.role.label)" + } +} + // MARK: - Sidebar row private struct OwnedListSidebarRow: View { diff --git a/App/Features/Lists/WatchedListsViewModel.swift b/App/Features/Lists/WatchedListsViewModel.swift new file mode 100644 index 0000000..37c520b --- /dev/null +++ b/App/Features/Lists/WatchedListsViewModel.swift @@ -0,0 +1,163 @@ +// WatchedListsViewModel +// +// Drives the "Shared with me" section of the Lists sidebar +// (work-consolidation.md G23 / issue #48) — the lists *other people* own and +// gave this account access to, which the web shows as a datagrid on `/lists` +// and macOS had no equivalent of at all. +// +// Deliberately a **separate** view model from `OwnedListsViewModel` rather +// than another array on it. The two collections come from different routes +// with independent failure modes, and the issue's acceptance criteria require +// that a failing `GET /api/lists/watching` leaves the owned lists rendering +// normally. Separate view models make that structural instead of a convention +// somebody has to remember. +// +// Reads through `ListsServicing` only, so unit tests substitute a stub. +// +// Per decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class WatchedListsViewModel { + + /// Page size for the watched-lists fetch. Matches `OwnedListsViewModel` + /// so the two sidebar sections page identically. + static let pageSize: Int = 50 + + /// Freshness window shared with `OwnedListsViewModel`: a re-appearing view + /// whose data refreshed within this many seconds skips revalidation. + static let refreshTTL: TimeInterval = 45 + + private let lists: ListsServicing + + // MARK: - Observable state + + /// Lists shared with this account, in the server's order. + private(set) var watched: [WatchedList] = [] + + /// True while a load round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load. Scoped to this + /// section: the owned-lists sidebar above keeps rendering regardless. + private(set) var error: Error? + + /// True once the first load resolved, so the view can tell "loading" from + /// "nobody has shared anything with you". + private(set) var hasLoadedOnce: Bool = false + + /// Whether the server reports more pages beyond what is loaded. + private(set) var hasMore: Bool = false + + /// The `offset` for the next `loadMore` call. `nil` when `hasMore` is false. + private(set) var nextOffset: Int? + + /// Timestamp of the last successful load; drives `shouldRefresh`. + private(set) var lastRefreshedAt: Date? + + /// Whether a `.task`-driven re-appearance should revalidate. + var shouldRefresh: Bool { + guard let lastRefreshedAt else { return true } + return Date().timeIntervalSince(lastRefreshedAt) >= Self.refreshTTL + } + + // MARK: - Init + + init(lists: ListsServicing) { + self.lists = lists + } + + // MARK: - Derived + + /// Watched lists grouped by the caller's role, so the sidebar can show + /// "what I can edit" apart from "what I can only read". Roles with no + /// lists are omitted; within a group the server's order is preserved. + var groupedByRole: [(role: ShareRole, lists: [WatchedList])] { + ShareRole.allCases.compactMap { role in + let matching = watched.filter { $0.role == role } + return matching.isEmpty ? nil : (role: role, lists: matching) + } + } + + /// Looks a watched list up by id — the rows pane resolves the sidebar + /// selection through this when it is not one of the owned lists. + func list(withID id: String?) -> WatchedList? { + guard let id else { return nil } + return watched.first { $0.id == id } + } + + // MARK: - Intents + + /// First load / manual refresh. Replaces the whole collection. + /// + /// A failure does **not** blank rows already on screen: the section keeps + /// what it has and surfaces `error` beside it, so a flaky `watching` call + /// never costs the user their shared lists mid-session. + func load() async { + isLoading = true + error = nil + defer { + isLoading = false + hasLoadedOnce = true + } + do { + let page = try await lists.watching(limit: Self.pageSize, offset: 0) + watched = page.lists + hasMore = page.hasMore + nextOffset = page.nextOffset + lastRefreshedAt = Date() + } catch { + self.error = error + } + } + + /// Appends the next page when one exists. No-op while a load is in flight + /// or when the server reported no more pages. + func loadMore() async { + guard !isLoading, hasMore, let offset = nextOffset else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await lists.watching(limit: Self.pageSize, offset: offset) + watched.append(contentsOf: page.lists) + hasMore = page.hasMore + nextOffset = page.nextOffset + error = nil + } catch { + self.error = error + } + } + + /// Applies a `ListsEvent`. Pure local mutation, no networking. + /// + /// Only `listDeleted` is meaningful here: a list this account merely + /// watches cannot be created or renamed from this client, but losing + /// access (or the owner deleting it) must drop the row. `listCreated` is + /// deliberately ignored — a list *this* account creates belongs to the + /// owned section, never here. + func apply(event: ListsEvent) { + switch event { + case .listDeleted(let id): + watched.removeAll { $0.id == id } + case .listUpdated(let list): + if let index = watched.firstIndex(where: { $0.id == list.id }) { + let existing = watched[index] + watched[index] = WatchedList( + list: list, + owner: existing.owner, + role: existing.role, + parentTitle: existing.parentTitle + ) + } + case .listCreated, .rowCreated, .rowUpdated, .rowDeleted, + .schemaChanged, + .watcherChanged, .watcherRemoved, + .connectionAdded, .connectionRemoved: + break + } + } +} diff --git a/App/Features/Lists/WatchersView.swift b/App/Features/Lists/WatchersView.swift index 0d17175..7629c6e 100644 --- a/App/Features/Lists/WatchersView.swift +++ b/App/Features/Lists/WatchersView.swift @@ -33,7 +33,6 @@ struct WatchersView: View { if viewModel == nil { let model = WatchersViewModel( lists: environment.lists, - userService: environment.userService, eventBus: environment.listsEventBus, listId: listId ) @@ -105,7 +104,7 @@ struct WatchersView: View { .foregroundStyle(.secondary) } Spacer() - Button("Add Watcher") { + Button("Add Person") { showAddWatcher = true } .buttonStyle(.bordered) @@ -122,7 +121,7 @@ struct WatchersView: View { .foregroundStyle(.secondary) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(watcher.username ?? watcher.userId) + Text(watcher.displayLabel) .font(.ilBody()) if let username = watcher.username { Text("@\(username)") @@ -131,19 +130,21 @@ struct WatchersView: View { } } Spacer() + // Labels follow the web's vocabulary (Read-only / Edit / Admin); + // `WatcherRole.label` is the single source for them. Picker("Role", selection: Binding( get: { watcher.role }, set: { newRole in Task { await viewModel.setRole(userId: watcher.userId, role: newRole) } } )) { - Text("Owner").tag(WatcherRole.owner) - Text("Editor").tag(WatcherRole.editor) - Text("Viewer").tag(WatcherRole.viewer) + ForEach(WatcherRole.allCases, id: \.self) { role in + Text(role.label).tag(role) + } } .pickerStyle(.menu) - .frame(width: 100) - .accessibilityLabel("Role for \(watcher.username ?? watcher.userId)") + .frame(width: 120) + .accessibilityLabel("Role for \(watcher.displayLabel)") Button { userIDPendingRemove = watcher.userId @@ -152,7 +153,7 @@ struct WatchersView: View { .foregroundStyle(.red) } .buttonStyle(.plain) - .accessibilityLabel("Remove \(watcher.username ?? watcher.userId)") + .accessibilityLabel("Remove \(watcher.displayLabel)") } .padding(.vertical, 2) } diff --git a/App/Features/Lists/WatchersViewModel.swift b/App/Features/Lists/WatchersViewModel.swift index 2082ec6..5eb45f9 100644 --- a/App/Features/Lists/WatchersViewModel.swift +++ b/App/Features/Lists/WatchersViewModel.swift @@ -2,10 +2,30 @@ // // Drives `WatchersView` — the M3 sharing panel (PLAN.md §6 M3, // "List sharing"). Owns the loaded watcher list, the role-edit -// flow, and the add-watcher (NW-1) flow. Role changes and watcher +// flow, and the add-watcher flow. Role changes and watcher // adds use the Wave 3 optimistic pattern: snapshot, mutate locally, // call the service; on success replace with the authoritative return // value, on failure restore the snapshot and surface the error. +// +// work-consolidation.md G23 (issue #48) changed three things here: +// +// 1. `load()` reads `watchers(of:)` instead of `watcherUsers(of:)`. The +// `/watchers/users` route is a *candidate search* ("people you could +// add"), not the watcher list — recon on 2026-09-09 showed it answering +// `{ users: [...] }` with no `userId` and no `role`, so the panel could +// never have listed anyone. `/watchers` now decodes its real +// `{ watchers: [...] }` envelope and carries the nested person object, +// so it has the display data the panel wants. +// 2. Adding a watcher calls the real `POST /api/lists/{id}/watchers` +// (`ListsServicing.addWatcher`) instead of re-purposing the role-change +// `PUT`. That route is subscriber-gated server-side, so a `403` arrives +// as `ListsError.subscriberRequired` and raises `showSubscriberUpsell` — +// the same pattern `ShareLinksViewModel` / `InvitesViewModel` use. +// 3. Picking someone to add goes through `watcherCandidates(of:search:)`, +// the endpoint built for exactly that (it auto-excludes current +// watchers), replacing the exact-handle lookup. +// +// Per decision 0003 this view model consumes only `InterlinedDomain`. import Foundation import Observation @@ -15,31 +35,38 @@ import InterlinedDomain @Observable final class WatchersViewModel { + /// How many candidates one search page returns. Matches the route's own + /// documented default so paging behaviour is predictable. + static let candidatePageSize: Int = 50 + private let lists: ListsServicing - private let userService: UserServicing private let eventBus: ListsEventBus let listId: String - /// Loaded watchers. `watcherUsers` returns the variant with the - /// username included; we prefer that to the bare `watchers` call - /// so the share panel always has display data. + /// Loaded watchers — everyone who currently has access to this list. private(set) var watchers: [ListWatcher] = [] private(set) var isLoading: Bool = false private(set) var error: Error? - /// The user found by the last `lookupAndAdd` call. Nil when no search has run - /// or when the handle was not found. The add-watcher sheet reads this. - private(set) var foundUser: UserSearchResult? - /// True while a handle lookup is in flight. - private(set) var isLookingUp: Bool = false + /// Candidates matching the most recent search. Empty before the first + /// search and after a search that matched nobody. + private(set) var candidates: [CollaboratorCandidate] = [] + /// True while a candidate search is in flight. + private(set) var isSearching: Bool = false + /// True once a search has resolved, so the sheet can tell "no results" + /// apart from "you have not searched yet". + private(set) var hasSearchedOnce: Bool = false + + /// Raised when an add / role change was blocked because the account is + /// not a subscriber. The view shows an upsell instead of an error banner. + private(set) var showSubscriberUpsell: Bool = false /// Pending in-flight write set, keyed by `userId`. De-bounces /// rapid role changes / remove clicks on the same row. private(set) var pendingOperations: Set = [] - init(lists: ListsServicing, userService: UserServicing, eventBus: ListsEventBus, listId: String) { + init(lists: ListsServicing, eventBus: ListsEventBus, listId: String) { self.lists = lists - self.userService = userService self.eventBus = eventBus self.listId = listId } @@ -49,7 +76,7 @@ final class WatchersViewModel { error = nil defer { isLoading = false } do { - watchers = try await lists.watcherUsers(of: listId) + watchers = try await lists.watchers(of: listId) } catch { self.error = error } @@ -63,6 +90,8 @@ final class WatchersViewModel { let optimistic = ListWatcher( userId: original.userId, username: original.username, + displayName: original.displayName, + avatarURL: original.avatarURL, role: role, createdAt: original.createdAt ) @@ -75,15 +104,26 @@ final class WatchersViewModel { userId: userId, role: role ) + // The route answers `{ role }` only, so keep the display fields we + // already have and take just the server's applied role. + let authoritative = ListWatcher( + userId: original.userId, + username: original.username, + displayName: original.displayName, + avatarURL: original.avatarURL, + role: confirmed.role, + createdAt: original.createdAt + ) if let currentIndex = watchers.firstIndex(where: { $0.userId == userId }) { - watchers[currentIndex] = confirmed + watchers[currentIndex] = authoritative } - eventBus.post(.watcherChanged(listId: listId, watcher: confirmed)) + eventBus.post(.watcherChanged(listId: listId, watcher: authoritative)) error = nil + } catch ListsError.subscriberRequired { + restore(original) + showSubscriberUpsell = true } catch { - if let rollbackIndex = watchers.firstIndex(where: { $0.userId == userId }) { - watchers[rollbackIndex] = original - } + restore(original) self.error = error } } @@ -113,6 +153,8 @@ final class WatchersViewModel { case .watcherChanged(let id, let watcher) where id == listId: if let index = watchers.firstIndex(where: { $0.userId == watcher.userId }) { watchers[index] = watcher + } else { + watchers.append(watcher) } case .watcherRemoved(let id, let userId) where id == listId: watchers.removeAll { $0.userId == userId } @@ -121,65 +163,98 @@ final class WatchersViewModel { } } - /// Looks up a user by exact handle. Populates `foundUser` on success; - /// sets `foundUser = nil` and `error` when not found or on upstream failure. - func lookupAndAdd(handle: String) async { - let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - isLookingUp = true - foundUser = nil + /// Searches people who could be added to this list. A blank query returns + /// the route's default (unfiltered) page, which is what the picker shows + /// when it first opens. + func searchCandidates(query: String) async { + isSearching = true error = nil - defer { isLookingUp = false } + defer { + isSearching = false + hasSearchedOnce = true + } do { - foundUser = try await userService.lookupUser(handle: trimmed) - if foundUser == nil { - error = WatchersError.userNotFound(trimmed) - } + candidates = try await lists.watcherCandidates( + of: listId, + search: query, + limit: Self.candidatePageSize + ) } catch { + candidates = [] self.error = error } } - /// Adds the looked-up user as a watcher with the given role. Optimistically - /// appends a provisional row, calls `setWatcher`, and on success replaces it - /// with the server's authoritative return value. On failure, removes the - /// provisional row and surfaces the error. - func addWatcher(userId: String, role: WatcherRole) async { + /// Adds `userId` as a watcher at `role`. Optimistically appends a + /// provisional row, calls the service, and on failure removes it again. + /// + /// `notify` maps to the route's own flag: `true` (the default) emails the + /// recipient, `false` grants access silently. + func addWatcher(candidate: CollaboratorCandidate, role: WatcherRole, notify: Bool = true) async { + let userId = candidate.id guard !pendingOperations.contains(userId) else { return } guard !watchers.contains(where: { $0.userId == userId }) else { error = WatchersError.alreadyWatcher return } let snapshot = watchers - let provisional = ListWatcher(userId: userId, username: foundUser?.username, role: role, createdAt: nil) + let provisional = ListWatcher( + userId: userId, + username: candidate.username, + displayName: candidate.displayName, + avatarURL: candidate.avatar, + role: role, + createdAt: nil + ) watchers.append(provisional) pendingOperations.insert(userId) + showSubscriberUpsell = false defer { pendingOperations.remove(userId) } do { - let confirmed = try await lists.setWatcher(listId: listId, userId: userId, role: role) - if let idx = watchers.firstIndex(where: { $0.userId == userId }) { - watchers[idx] = confirmed - } - eventBus.post(.watcherChanged(listId: listId, watcher: confirmed)) - foundUser = nil + try await lists.addWatcher( + listId: listId, + userId: userId, + role: role, + notify: notify + ) + // The route answers `{ watching: true }` rather than the new row, so + // the provisional entry *is* the authoritative local state; a later + // `load()` reconciles it with the server's `createdAt`. + eventBus.post(.watcherChanged(listId: listId, watcher: provisional)) + // Drop the person from the candidate list so they cannot be added + // twice before the next search refreshes it. + candidates.removeAll { $0.id == userId } error = nil + } catch ListsError.subscriberRequired { + watchers = snapshot + showSubscriberUpsell = true } catch { watchers = snapshot self.error = error } } + + /// Dismisses the subscriber upsell. + func dismissSubscriberUpsell() { + showSubscriberUpsell = false + } + + /// Rolls an optimistic role flip back. Re-finds the row by id rather than + /// trusting the pre-call index — the event bus can reorder `watchers` + /// while the write is in flight. + private func restore(_ watcher: ListWatcher) { + guard let index = watchers.firstIndex(where: { $0.userId == watcher.userId }) else { return } + watchers[index] = watcher + } } enum WatchersError: LocalizedError, Equatable { - case userNotFound(String) case alreadyWatcher var errorDescription: String? { switch self { - case .userNotFound(let handle): - return "@\(handle) was not found." case .alreadyWatcher: - return "That user is already watching this list." + return "That person already has access to this list." } } } diff --git a/App/Features/Sharing/InviteLandingView.swift b/App/Features/Sharing/InviteLandingView.swift new file mode 100644 index 0000000..fbdb5d3 --- /dev/null +++ b/App/Features/Sharing/InviteLandingView.swift @@ -0,0 +1,169 @@ +// InviteLandingView +// +// The email-invite landing (work-consolidation.md G23 / issue #48). Presented +// by `ResolveShareView` when the opened link is an `…/invite/{token}` rather +// than a `…/shared/{token}`, so `MainWindowView`'s existing share-link sheet is +// the only presentation point either flavour needs. +// +// Deliberately has **no Accept button**: the claim route is session-only in the +// live spec, so a native Accept would 401 every time. The primary action opens +// the invite in the browser, which is where the grant can actually be made. +// +// Per Decision 0003 the view imports only InterlinedDomain. + +import SwiftUI +import InterlinedDomain + +struct InviteLandingView: View { + + let parsed: ParsedShare + let environment: AppEnvironment + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var viewModel: InviteLandingViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Opening invite") + .padding() + } + } + .frame(minWidth: 420, minHeight: 300) + .task { + if viewModel == nil { + let model = InviteLandingViewModel( + service: environment.sharing, + parsed: parsed, + webBaseURL: environment.shareBaseURL + ) + viewModel = model + await model.resolve() + } + } + } + + @ViewBuilder + private func content(viewModel: InviteLandingViewModel) -> some View { + VStack(alignment: .leading, spacing: 16) { + header + + if viewModel.isLoading && viewModel.invite == nil { + ProgressView("Opening invite…") + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } else if viewModel.isUnsupportedResource { + unsupportedState + } else if let error = viewModel.error, viewModel.invite == nil { + errorState(error: error, viewModel: viewModel) + } else if let invite = viewModel.invite { + resolvedState(invite: invite, guidance: viewModel.guidance) + } + + Spacer() + footer(viewModel: viewModel) + } + .padding(16) + } + + // MARK: - States + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("You're Invited") + .font(.ilTitle(20)) + Text("Someone invited you to a \(parsed.kind == .list ? "list" : "document") by email.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + private func resolvedState(invite: ResolvedListInvite, guidance: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: invite.accepted ? "checkmark.circle.fill" : "envelope.open") + .font(.ilDisplay(28)) + .foregroundStyle(invite.accepted ? Color.green : Color.accentColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(invite.resourceTitle ?? "A shared list") + .font(.ilSubtitle()) + Text("Grants \(invite.role.label) access") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + } + + noticeBox( + systemImage: invite.wrongAccount + ? "person.crop.circle.badge.exclamationmark" + : "safari", + text: guidance + ) + } + } + + private var unsupportedState: some View { + noticeBox( + systemImage: "safari", + text: "Open this invite in your browser to see what it grants and accept it." + ) + } + + private func errorState(error: Error, viewModel: InviteLandingViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label("Couldn't open this invite", systemImage: "exclamationmark.triangle") + .font(.ilBody().weight(.semibold)) + // The server returns one 404 for unknown / expired / revoked, so + // the copy names all three rather than guessing. + Text("The invite may have expired, been revoked, or never existed.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + Text(error.localizedDescription) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { await viewModel.resolve() } + } + .buttonStyle(.bordered) + } + } + + private func noticeBox(systemImage: String, text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(text) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + } + + // MARK: - Footer + + @ViewBuilder + private func footer(viewModel: InviteLandingViewModel) -> some View { + HStack { + Button("Close") { dismiss() } + .buttonStyle(.bordered) + Spacer() + if let url = viewModel.acceptInBrowserURL { + Button("Accept in Browser") { + openURL(url) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .help("Accepting an invite needs a web session — the app can't complete it yet") + } + } + } +} diff --git a/App/Features/Sharing/InviteLandingViewModel.swift b/App/Features/Sharing/InviteLandingViewModel.swift new file mode 100644 index 0000000..9fd67be --- /dev/null +++ b/App/Features/Sharing/InviteLandingViewModel.swift @@ -0,0 +1,118 @@ +// InviteLandingViewModel +// +// Drives `InviteLandingView` — the landing shown when the user opens a +// `…/lists/invite/{token}` link, pasted or delivered via the +// `interlinedlist://` deep-link scheme (work-consolidation.md G23 / issue #48). +// +// **There is no native Accept.** `POST /api/lists/invite/{token}` is declared +// `x-auth-type: session` in the live OpenAPI spec, so a Bearer-only macOS +// client cannot claim an invite at all. Shipping an Accept button here would +// ship a guaranteed 401, so the landing shows what the invite grants and hands +// the accept step to the browser. When the backend exposes a Bearer-reachable +// claim route, `acceptInBrowserURL` is the seam that gets replaced with a real +// `claim()` intent — nothing else here changes. +// +// Accepting an invite is documented as always free, so no entitlement gate +// belongs anywhere in this flow. +// +// Reads through `SharingServicing` only. Per decision 0003 it consumes only +// `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class InviteLandingViewModel { + + // MARK: - Dependencies + + private let service: SharingServicing + let parsed: ParsedShare + /// Where the browser hand-off points. Injected so the view model stays + /// free of any environment lookup and is trivially unit-testable. + private let webBaseURL: URL + + // MARK: - Observable state + + /// The resolved invite once `resolve()` succeeds. + private(set) var invite: ResolvedListInvite? + + /// True while the resolve round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed resolve. An unknown, + /// expired, or revoked token arrives here as an `APIError.notFound` — the + /// server makes those three cases deliberately indistinguishable. + private(set) var error: Error? + + /// True when the link is an invite for a resource this client cannot + /// resolve. Only list invites have a domain path today (issue #48 scopes + /// the work to Lists); a document invite still gets a usable landing that + /// sends the user to the browser rather than silently doing nothing. + private(set) var isUnsupportedResource: Bool = false + + // MARK: - Init + + init(service: SharingServicing, parsed: ParsedShare, webBaseURL: URL) { + self.service = service + self.parsed = parsed + self.webBaseURL = webBaseURL + } + + // MARK: - Derived UI state + + /// The web address that completes the invite. Always offered — it is the + /// only route to acceptance from this client. + var acceptInBrowserURL: URL? { + ShareURLParser.webURL( + base: webBaseURL, + kind: parsed.kind, + token: parsed.token, + mode: .invite + ) + } + + /// One line explaining what the user should do next, chosen from the flags + /// the landing payload returns. Ordered most-specific first: an already + /// accepted invite is a dead end regardless of who is signed in, and a + /// wrong-account signal outranks a plain "sign in". + var guidance: String { + guard let invite else { return "" } + if invite.accepted { + return "This invite has already been accepted." + } + if invite.wrongAccount { + return "You're signed in with a different email than the one invited. Switch accounts in your browser to accept." + } + if invite.needsAuth { + return "Sign in on the web to accept this invite." + } + if invite.canClaim { + return "This invite is ready to accept. Accepting happens in your browser — the app can't complete it yet." + } + return "Open this invite in your browser to accept it." + } + + // MARK: - Intents + + /// Resolves the token, populating `invite` (role / title / flags). + func resolve() async { + guard !isLoading else { return } + guard parsed.kind == .list else { + // Not an error — just outside what this client can read today. + isUnsupportedResource = true + return + } + isLoading = true + defer { isLoading = false } + do { + invite = try await service.resolveListInvite(token: parsed.token) + error = nil + } catch { + invite = nil + self.error = error + } + } +} diff --git a/App/Features/Sharing/ResolveShareView.swift b/App/Features/Sharing/ResolveShareView.swift index 9366e9d..41eda3f 100644 --- a/App/Features/Sharing/ResolveShareView.swift +++ b/App/Features/Sharing/ResolveShareView.swift @@ -28,7 +28,13 @@ struct ResolveShareView: View { var body: some View { Group { - if let viewModel { + if parsed.mode == .invite { + // An email invite is a different landing with a different + // ending (the accept step is session-only, so it happens in + // the browser). Branching here rather than in `MainWindowView` + // keeps the deep-link plumbing to a single routed sheet. + InviteLandingView(parsed: parsed, environment: environment) + } else if let viewModel { content(viewModel: viewModel) } else { ProgressView() @@ -38,6 +44,7 @@ struct ResolveShareView: View { } .frame(minWidth: 420, minHeight: 300) .task { + guard parsed.mode == .share else { return } if viewModel == nil { let model = ResolveShareViewModel( service: environment.sharing, @@ -105,9 +112,83 @@ struct ResolveShareView: View { if viewModel.needsSignIn { signInPrompt } + + if parsed.kind == .list { + sharedRows(viewModel: viewModel) + } } } + /// The shared list's rows (work-consolidation.md G23). A read-only share is + /// only useful if it shows the list; the token authorises this read on its + /// own, so the rows appear whether or not the viewer is signed in. + @ViewBuilder + private func sharedRows(viewModel: ResolveShareViewModel) -> some View { + VStack(alignment: .leading, spacing: 6) { + Divider() + if viewModel.isLoadingRows { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading rows") + } else if let rowsError = viewModel.rowsError { + // Scoped to the rows section: the title and role above stay + // on screen, because they resolved fine. + VStack(alignment: .leading, spacing: 4) { + Label("Couldn't load this list's rows", systemImage: "exclamationmark.triangle") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + Text(rowsError.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { await viewModel.loadRows() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } else if viewModel.rows.isEmpty { + if viewModel.hasLoadedRowsOnce { + Text("This list has no rows yet.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + } else { + Text("\(viewModel.rows.count) row\(viewModel.rows.count == 1 ? "" : "s")") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(viewModel.rows) { row in + sharedRowCard(row: row, columns: viewModel.columns) + } + } + } + .frame(maxHeight: 220) + } + } + } + + private func sharedRowCard(row: ListRow, columns: [String]) -> some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(columns, id: \.self) { column in + if let value = row.fields[column] { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(column) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Text(value.displayText) + .font(.ilMono(11)) + .lineLimit(2) + } + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .accessibilityElement(children: .combine) + } + private var signInPrompt: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "person.crop.circle.badge.exclamationmark") diff --git a/App/Features/Sharing/ResolveShareViewModel.swift b/App/Features/Sharing/ResolveShareViewModel.swift index 94e2171..60654f0 100644 --- a/App/Features/Sharing/ResolveShareViewModel.swift +++ b/App/Features/Sharing/ResolveShareViewModel.swift @@ -56,6 +56,33 @@ final class ResolveShareViewModel { /// `claim()` succeeds. Lets the caller navigate to the resource. private(set) var claim: ShareClaim? + // MARK: Shared row data (work-consolidation.md G23 / issue #48) + + /// The shared list's rows, read through `GET /api/lists/shared/{token}/data`. + /// + /// This is the read-only viewer's missing half: resolving a token gives the + /// title and the granted role, but until G23 the landing showed nothing of + /// the list itself. The token is the capability — rows come back with no + /// session and regardless of the list's `isPublic` flag — so a Viewer link + /// to a private list renders its contents here. + private(set) var rows: [ListRow] = [] + + /// True while the row read is in flight. Separate from `isLoading` so the + /// rows section can spin without blocking the claim button. + private(set) var isLoadingRows: Bool = false + + /// Surfaced error from the row read. Kept apart from `error` so a rows + /// failure never hides a successfully resolved title and role. + private(set) var rowsError: Error? + + /// True once the row read resolved, so the view can tell "loading" from + /// "this list has no rows". + private(set) var hasLoadedRowsOnce: Bool = false + + /// How many rows the landing previews. A share landing is a preview, not + /// the full grid — the user opens the list proper once they have access. + static let rowPageSize: Int = 100 + // MARK: - Init init(service: SharingServicing, parsed: ParsedShare, currentUserID: String?) { @@ -86,7 +113,12 @@ final class ResolveShareViewModel { // MARK: - Intents - /// Resolves the token, populating `resolved` (title / role / claimable). + /// Resolves the token, populating `resolved` (title / role / claimable), + /// then — for a list share — reads the first page of its rows. + /// + /// The row read runs only after a successful resolve: an unknown, expired, + /// or revoked token would fail both calls identically, and spending a + /// second round-trip to learn the same 404 helps nobody. func resolve() async { guard !isLoading else { return } isLoading = true @@ -97,6 +129,33 @@ final class ResolveShareViewModel { } catch { resolved = nil self.error = error + return + } + if parsed.kind == .list { + await loadRows() + } + } + + /// Reads one page of the shared list's rows. Public so the view can retry + /// the rows half on its own without re-resolving the token. + func loadRows() async { + guard parsed.kind == .list, !isLoadingRows else { return } + isLoadingRows = true + rowsError = nil + defer { + isLoadingRows = false + hasLoadedRowsOnce = true + } + do { + let page = try await service.sharedListRows( + token: parsed.token, + limit: Self.rowPageSize, + offset: 0 + ) + rows = page.rows + } catch { + rows = [] + rowsError = error } } @@ -127,6 +186,21 @@ final class ResolveShareViewModel { currentUserID = id } + /// Stable column order derived from the loaded rows' field keys, sorted so + /// the preview renders deterministically. Mirrors `ListDetailViewModel`: + /// the schema-defined order is not available on a token-scoped read. + var columns: [String] { + var seen = Set() + var ordered: [String] = [] + for row in rows { + for key in row.fields.keys where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + } + return ordered.sorted() + } + // MARK: - Target dispatch private func resolveShare() async throws -> ResolvedShare { diff --git a/App/Features/Sharing/ShareURLParser.swift b/App/Features/Sharing/ShareURLParser.swift index 851a8a8..c9852d1 100644 --- a/App/Features/Sharing/ShareURLParser.swift +++ b/App/Features/Sharing/ShareURLParser.swift @@ -10,6 +10,13 @@ // • interlinedlist://lists/shared/{token} (host = "lists") // • interlinedlist://share/lists/shared/{token} (host = "share") // +// work-consolidation.md G23 adds the **email-invite** landing to the same +// parser and the same routed sheet: `…/lists/invite/{token}` and +// `…/documents/invite/{token}` parse to the identical `ParsedShare` carrying +// `mode == .invite`. Reusing one notification and one presented view keeps +// `MainWindowView` untouched — it already forwards whatever `ParsedShare` it +// receives, and `ResolveShareView` branches on the mode. +// // The App's deep-link handler and the "paste a share link" field both call // `ShareURLParser.parse(_:)`; keeping the logic here (pure, string-only) // means it is exhaustively unit-testable without a live URL round-trip and @@ -20,12 +27,33 @@ import Foundation -/// A share reference extracted from a URL — which resource kind, and the -/// opaque token to resolve. +/// A share reference extracted from a URL — which resource kind, which flavour +/// of link, and the opaque token to resolve. struct ParsedShare: Equatable { enum Kind: Equatable { case list, document } + + /// Which landing the token belongs to. + enum Mode: Equatable { + /// A tokenized share link (`…/shared/{token}`) — resolvable and, for + /// Editor/Admin links, claimable. + case share + /// An email invite (`…/invite/{token}`). Resolvable for its landing + /// page; the accept half is session-only server-side, so the client + /// hands that step to the browser. + case invite + } + let kind: Kind let token: String + let mode: Mode + + /// `mode` defaults to `.share` so every G3-era call site — and its tests — + /// reads unchanged. + init(kind: Kind, token: String, mode: Mode = .share) { + self.kind = kind + self.token = token + self.mode = mode + } } enum ShareURLParser { @@ -63,33 +91,47 @@ enum ShareURLParser { return parse(url) } - /// Builds the canonical web share URL for a resource + token, used by - /// the share panel's copy affordance when the server did not return a - /// pre-built `ShareLink.url`. - static func webURL(base: URL, kind: ParsedShare.Kind, token: String) -> URL? { + /// Builds the canonical web URL for a resource + token, used by the share + /// panel's copy affordance when the server did not return a pre-built + /// `ShareLink.url`, and by the invite landing's "Accept in your browser" + /// button (the claim route is session-only, so the browser is the only + /// place the grant can be completed). + static func webURL( + base: URL, + kind: ParsedShare.Kind, + token: String, + mode: ParsedShare.Mode = .share + ) -> URL? { let resource = kind == .list ? "lists" : "documents" + let marker = mode == .share ? "shared" : "invite" return base .appendingPathComponent(resource) - .appendingPathComponent("shared") + .appendingPathComponent(marker) .appendingPathComponent(token) } // MARK: - Matching - /// Matches `[… , , "shared", ]` at the tail of the - /// segment list. Anything else returns `nil`. + /// Matches `[… , , "shared"|"invite", ]` at the tail of + /// the segment list. Anything else returns `nil`. private static func match(_ segments: [String]) -> ParsedShare? { // Ignore a leading "share" router segment if present. let cleaned = segments.filter { $0.lowercased() != "share" } guard cleaned.count >= 3 else { return nil } let tail = Array(cleaned.suffix(3)) let resource = tail[0].lowercased() - let sharedMarker = tail[1].lowercased() + let marker = tail[1].lowercased() let token = tail[2] - guard sharedMarker == "shared", !token.isEmpty else { return nil } + guard !token.isEmpty else { return nil } + let mode: ParsedShare.Mode + switch marker { + case "shared": mode = .share + case "invite": mode = .invite + default: return nil + } switch resource { - case "lists": return ParsedShare(kind: .list, token: token) - case "documents": return ParsedShare(kind: .document, token: token) + case "lists": return ParsedShare(kind: .list, token: token, mode: mode) + case "documents": return ParsedShare(kind: .document, token: token, mode: mode) default: return nil } } diff --git a/AppTests/ContributorsViewModelTests.swift b/AppTests/ContributorsViewModelTests.swift new file mode 100644 index 0000000..eba76ca --- /dev/null +++ b/AppTests/ContributorsViewModelTests.swift @@ -0,0 +1,84 @@ +// ContributorsViewModelTests +// +// BDD-named tests for the ranked contributor panel +// (work-consolidation.md G23 / issue #48). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ContributorsViewModelTests: XCTestCase { + + func test_givenRankedContributors_whenLoading_thenPreservesServerOrder() async { + // The server owns the ranking formula; the client must not re-sort. + let stub = StubListsService() + await stub.enqueueContributors(success: [ + ListsFixtures.contributor(id: "c1", addedCount: 17, editedCount: 17, score: 34), + ListsFixtures.contributor(id: "c2", addedCount: 2, editedCount: 0, score: 2) + ]) + let viewModel = ContributorsViewModel(lists: stub, listId: "L1") + + await viewModel.load() + + XCTAssertEqual(viewModel.contributors.map(\.id), ["c1", "c2"]) + XCTAssertEqual(viewModel.totalScore, 36) + XCTAssertNil(viewModel.error) + XCTAssertTrue(viewModel.hasLoadedOnce) + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .contributors(listId: "L1")) + } + + func test_givenNoContributors_whenLoading_thenLeavesEmptyAndTotalIsZero() async { + // Boundary: a list nobody has touched. `totalScore == 0` is what the + // view guards on before dividing to show a percentage. + let stub = StubListsService() + await stub.enqueueContributors(success: []) + let viewModel = ContributorsViewModel(lists: stub, listId: "L1") + + await viewModel.load() + + XCTAssertTrue(viewModel.contributors.isEmpty) + XCTAssertEqual(viewModel.totalScore, 0) + XCTAssertTrue(viewModel.hasLoadedOnce) + } + + func test_givenAPIFailure_whenLoading_thenSurfacesError() async { + let stub = StubListsService() + await stub.enqueueContributors(failure: TestError.upstream("denied")) + let viewModel = ContributorsViewModel(lists: stub, listId: "L1") + + await viewModel.load() + + XCTAssertEqual(viewModel.error as? TestError, .upstream("denied")) + XCTAssertTrue(viewModel.contributors.isEmpty) + } + + func test_givenPriorFailure_whenRetrySucceeds_thenClearsErrorAndPopulates() async { + // The panel's "Try Again" path. + let stub = StubListsService() + await stub.enqueueContributors(failure: TestError.upstream("boom")) + await stub.enqueueContributors(success: [ListsFixtures.contributor(id: "c1", score: 3)]) + let viewModel = ContributorsViewModel(lists: stub, listId: "L1") + await viewModel.load() + + await viewModel.load() + + XCTAssertNil(viewModel.error) + XCTAssertEqual(viewModel.contributors.map(\.id), ["c1"]) + } + + func test_givenContributorWithoutNames_whenLoading_thenFallsBackToNeutralLabel() async { + // Invalid/partial upstream row: a contributor with no display name and + // no username still renders a readable row. + let stub = StubListsService() + await stub.enqueueContributors(success: [ + ListsFixtures.contributor(id: "c1", username: nil, displayName: nil) + ]) + let viewModel = ContributorsViewModel(lists: stub, listId: "L1") + + await viewModel.load() + + XCTAssertEqual(viewModel.contributors.first?.displayLabel, "Contributor") + } +} diff --git a/AppTests/InviteLandingViewModelTests.swift b/AppTests/InviteLandingViewModelTests.swift new file mode 100644 index 0000000..912670d --- /dev/null +++ b/AppTests/InviteLandingViewModelTests.swift @@ -0,0 +1,178 @@ +// InviteLandingViewModelTests +// +// BDD-named tests for the email-invite landing (work-consolidation.md G23 / +// issue #48). +// +// The central contract is negative: this view model must never offer a native +// accept. `POST /api/lists/invite/{token}` is session-only in the live spec, so +// the only path forward is the browser hand-off — and the guidance copy has to +// say so for every combination of flags the landing payload returns. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class InviteLandingViewModelTests: XCTestCase { + + private let webBase = URL(string: "https://interlinedlist.com")! + + private func makeViewModel( + parsed: ParsedShare = ParsedShare(kind: .list, token: "tok", mode: .invite) + ) -> (InviteLandingViewModel, StubSharingService) { + let service = StubSharingService() + let vm = InviteLandingViewModel(service: service, parsed: parsed, webBaseURL: webBase) + return (vm, service) + } + + // MARK: - resolve + + func test_givenClaimableInvite_whenResolving_thenPopulatesRoleAndTitle() async { + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(success: ResolvedListInvite( + role: .collaborator, + needsAuth: false, + canClaim: true, + wrongAccount: false, + accepted: false, + resourceTitle: "Q3 Planning" + )) + + await vm.resolve() + + XCTAssertEqual(vm.invite?.role, .collaborator) + XCTAssertEqual(vm.invite?.resourceTitle, "Q3 Planning") + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded.first?.kind, .resolveListInvite(token: "tok")) + } + + func test_givenDocumentInvite_whenResolving_thenReportsUnsupportedWithoutCallingService() async { + // Invalid input for this client: issue #48 scopes the invite work to + // Lists, so a document invite gets the browser hand-off rather than a + // dead deep link — and no service call is made. + let (vm, service) = makeViewModel( + parsed: ParsedShare(kind: .document, token: "dtok", mode: .invite) + ) + + await vm.resolve() + + XCTAssertTrue(vm.isUnsupportedResource) + XCTAssertNil(vm.invite) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenExpiredToken_whenResolving_thenSurfacesError() async { + // Upstream failure: unknown / expired / revoked are one 404. + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(failure: TestError.upstream("not-found")) + + await vm.resolve() + + XCTAssertNil(vm.invite) + XCTAssertEqual(vm.error as? TestError, .upstream("not-found")) + } + + func test_givenAnonymousResolveWithMinimalFlags_whenResolving_thenPromptsSignIn() async { + // Boundary: the payload can arrive with almost nothing set. + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(success: ResolvedListInvite( + role: .watcher, + needsAuth: true, + canClaim: false, + wrongAccount: false, + accepted: false, + resourceTitle: nil + )) + + await vm.resolve() + + XCTAssertNil(vm.invite?.resourceTitle) + XCTAssertEqual(vm.guidance, "Sign in on the web to accept this invite.") + } + + // MARK: - guidance precedence + + func test_givenAcceptedInvite_whenReadingGuidance_thenSaysAlreadyAccepted() async { + // Most specific first: an accepted invite is a dead end regardless of + // who is signed in. + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(success: ResolvedListInvite( + role: .collaborator, + needsAuth: true, + canClaim: true, + wrongAccount: true, + accepted: true, + resourceTitle: "Q3" + )) + + await vm.resolve() + + XCTAssertEqual(vm.guidance, "This invite has already been accepted.") + } + + func test_givenWrongAccount_whenReadingGuidance_thenOutranksSignInPrompt() async { + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(success: ResolvedListInvite( + role: .collaborator, + needsAuth: true, + canClaim: false, + wrongAccount: true, + accepted: false, + resourceTitle: "Q3" + )) + + await vm.resolve() + + XCTAssertTrue(vm.guidance.contains("different email")) + } + + func test_givenClaimableInvite_whenReadingGuidance_thenSaysAcceptHappensInBrowser() async { + // The negative contract, in the user-facing copy. + let (vm, service) = makeViewModel() + await service.enqueueResolveInvite(success: ResolvedListInvite( + role: .collaborator, + needsAuth: false, + canClaim: true, + wrongAccount: false, + accepted: false, + resourceTitle: "Q3" + )) + + await vm.resolve() + + XCTAssertTrue(vm.guidance.contains("browser")) + } + + func test_givenNoInviteResolved_whenReadingGuidance_thenIsEmpty() { + // Boundary: nothing resolved yet, so there is nothing to advise. + let (vm, _) = makeViewModel() + + XCTAssertEqual(vm.guidance, "") + } + + // MARK: - browser hand-off + + func test_givenListInvite_whenBuildingAcceptURL_thenPointsAtTheWebInvitePath() { + let (vm, _) = makeViewModel() + + XCTAssertEqual( + vm.acceptInBrowserURL?.absoluteString, + "https://interlinedlist.com/lists/invite/tok" + ) + } + + func test_givenDocumentInvite_whenBuildingAcceptURL_thenUsesTheDocumentPath() { + // Even the unsupported case gets a working hand-off. + let (vm, _) = makeViewModel( + parsed: ParsedShare(kind: .document, token: "dtok", mode: .invite) + ) + + XCTAssertEqual( + vm.acceptInBrowserURL?.absoluteString, + "https://interlinedlist.com/documents/invite/dtok" + ) + } +} diff --git a/AppTests/ResolveShareViewModelTests.swift b/AppTests/ResolveShareViewModelTests.swift index be9e160..40851d4 100644 --- a/AppTests/ResolveShareViewModelTests.swift +++ b/AppTests/ResolveShareViewModelTests.swift @@ -57,8 +57,10 @@ final class ResolveShareViewModelTests: XCTestCase { XCTAssertTrue(vm.canOfferClaim, "Signed-in + claimable → claim offered") XCTAssertFalse(vm.needsSignIn) XCTAssertNil(vm.error) + // A list resolve is followed by the shared-row read (G23), so assert + // on the first call rather than the whole log. let recorded = await service.recorded - XCTAssertEqual(recorded, [.init(kind: .resolveList(token: "tok"))]) + XCTAssertEqual(recorded.first, .init(kind: .resolveList(token: "tok"))) } func test_givenClaimableShare_whenClaiming_thenRecordsAuthoritativeClaim() async { @@ -88,9 +90,10 @@ final class ResolveShareViewModelTests: XCTestCase { XCTAssertFalse(vm.didClaim) XCTAssertNil(vm.claim) - // Only the resolve call was recorded — the guard skipped the claim. + // No claim call was recorded — the guard skipped it. (A list resolve is + // now followed by the G23 shared-row read, so filter to claims.) let recorded = await service.recorded - XCTAssertEqual(recorded, [.init(kind: .resolveList(token: "tok"))]) + XCTAssertFalse(recorded.contains(.init(kind: .claimList(token: "tok")))) } // MARK: - Upstream API failure @@ -179,6 +182,126 @@ final class ResolveShareViewModelTests: XCTestCase { XCTAssertEqual(recorded, [.init(kind: .resolveDocument(token: "dtok"))]) } + // MARK: - Shared row data (work-consolidation.md G23 / issue #48) + + func test_givenListShare_whenResolving_thenAlsoReadsTheSharedRows() async { + // Happy path: a read-only viewer must see the rows, not just the title. + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueSharedRows(success: ListsFixtures.rowsPage([ + ListsFixtures.row(id: "r1", fields: ["Title": .string("Dune")]), + ListsFixtures.row(id: "r2", fields: ["Title": .string("Neuromancer")]) + ])) + + await vm.resolve() + + XCTAssertEqual(vm.rows.map(\.id), ["r1", "r2"]) + XCTAssertEqual(vm.columns, ["Title"]) + XCTAssertNil(vm.rowsError) + XCTAssertTrue(vm.hasLoadedRowsOnce) + let recorded = await service.recorded + XCTAssertEqual( + recorded.last, + .init(kind: .sharedListRows( + token: "tok", + limit: ResolveShareViewModel.rowPageSize, + offset: 0 + )) + ) + } + + func test_givenDocumentShare_whenResolving_thenSkipsTheRowRead() async { + // Invalid target: documents have no row-data route — the resolve call + // returns their body directly — so no read may be attempted. + let (vm, service) = makeViewModel(parsed: ParsedShare(kind: .document, token: "dtok")) + await service.enqueueResolve(success: resolved( + resource: .document(id: "D1", title: "Spec", isPublic: false) + )) + + await vm.resolve() + + XCTAssertTrue(vm.rows.isEmpty) + XCTAssertFalse(vm.hasLoadedRowsOnce) + let recorded = await service.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenRowsReadFails_whenResolving_thenKeepsResolvedHeaderAndScopesError() async { + // Upstream failure: the rows half failing must not hide the title and + // role that resolved fine. + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueSharedRows(failure: TestError.upstream("rows-500")) + + await vm.resolve() + + XCTAssertEqual(vm.resolved?.resource?.title, "Roadmap") + XCTAssertNil(vm.error, "The resolve succeeded; its error slot stays clear.") + XCTAssertEqual(vm.rowsError as? TestError, .upstream("rows-500")) + XCTAssertTrue(vm.rows.isEmpty) + } + + func test_givenResolveFails_whenResolving_thenSkipsTheRowRead() async { + // A dead token would fail both calls identically; spending the second + // round-trip to learn the same 404 helps nobody. + let (vm, service) = makeViewModel() + await service.enqueueResolve(failure: TestError.upstream("gone")) + + await vm.resolve() + + XCTAssertNil(vm.resolved) + let recorded = await service.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenEmptySharedList_whenResolving_thenReportsLoadedWithNoRows() async { + // Boundary: an empty shared list. `hasLoadedRowsOnce` lets the view + // say "no rows yet" instead of leaving a blank gap. + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueSharedRows(success: ListsFixtures.rowsPage([])) + + await vm.resolve() + + XCTAssertTrue(vm.rows.isEmpty) + XCTAssertTrue(vm.columns.isEmpty) + XCTAssertTrue(vm.hasLoadedRowsOnce) + XCTAssertNil(vm.rowsError) + } + + func test_givenPriorRowsFailure_whenRetryingRows_thenPopulatesWithoutReResolving() async { + // The view's rows-only "Try Again" path. + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueSharedRows(failure: TestError.upstream("boom")) + await service.enqueueSharedRows(success: ListsFixtures.rowsPage([ + ListsFixtures.row(id: "r1", fields: ["Title": .string("Dune")]) + ])) + await vm.resolve() + + await vm.loadRows() + + XCTAssertEqual(vm.rows.map(\.id), ["r1"]) + XCTAssertNil(vm.rowsError) + let recorded = await service.recorded + XCTAssertEqual(recorded.filter { $0.kind == .resolveList(token: "tok") }.count, 1) + } + + func test_givenRowsWithDifferentColumns_whenResolving_thenColumnsAreUnionedAndSorted() async { + // Boundary: a token-scoped read carries no schema, so the column order + // is derived from the rows and must be deterministic. + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueSharedRows(success: ListsFixtures.rowsPage([ + ListsFixtures.row(id: "r1", fields: ["Year": .int(1965)]), + ListsFixtures.row(id: "r2", fields: ["Title": .string("Dune")]) + ])) + + await vm.resolve() + + XCTAssertEqual(vm.columns, ["Title", "Year"]) + } + func test_givenDocumentToken_whenClaiming_thenCallsDocumentClaimEndpoint() async { let (vm, service) = makeViewModel(parsed: ParsedShare(kind: .document, token: "dtok")) await service.enqueueResolve(success: resolved( diff --git a/AppTests/ShareURLParserTests.swift b/AppTests/ShareURLParserTests.swift index 9f529b6..498c3a8 100644 --- a/AppTests/ShareURLParserTests.swift +++ b/AppTests/ShareURLParserTests.swift @@ -105,6 +105,63 @@ final class ShareURLParserTests: XCTestCase { XCTAssertEqual(docURL?.absoluteString, "https://interlinedlist.com/documents/shared/tok") } + // MARK: - Invite links (work-consolidation.md G23 / issue #48) + + func test_givenHttpsListInviteURL_whenParsing_thenReturnsInviteMode() { + let url = URL(string: "https://interlinedlist.com/lists/invite/xN3v9Qk")! + XCTAssertEqual( + ShareURLParser.parse(url), + ParsedShare(kind: .list, token: "xN3v9Qk", mode: .invite) + ) + } + + func test_givenHttpsDocumentInviteURL_whenParsing_thenReturnsInviteMode() { + let url = URL(string: "https://interlinedlist.com/documents/invite/tok")! + XCTAssertEqual( + ShareURLParser.parse(url), + ParsedShare(kind: .document, token: "tok", mode: .invite) + ) + } + + func test_givenCustomSchemeInviteURL_whenParsing_thenParsesHostAsResource() { + let url = URL(string: "interlinedlist://lists/invite/tok")! + XCTAssertEqual( + ShareURLParser.parse(url), + ParsedShare(kind: .list, token: "tok", mode: .invite) + ) + } + + func test_givenShareURL_whenParsing_thenDefaultsToShareMode() { + // Boundary between the two modes: a `shared` link must never be + // mistaken for an invite, since the two landings differ. + let parsed = ShareURLParser.parse(URL(string: "https://interlinedlist.com/lists/shared/tok")!) + XCTAssertEqual(parsed?.mode, .share) + } + + func test_givenUnknownMarkerSegment_whenParsing_thenReturnsNil() { + // Invalid input: only `shared` and `invite` are recognized markers. + XCTAssertNil(ShareURLParser.parse(URL(string: "https://interlinedlist.com/lists/inviting/tok")!)) + XCTAssertNil(ShareURLParser.parse(URL(string: "https://interlinedlist.com/lists/invite/")!)) + } + + func test_givenInviteMode_whenBuildingWebURL_thenUsesTheInvitePath() { + let base = URL(string: "https://interlinedlist.com")! + let url = ShareURLParser.webURL(base: base, kind: .list, token: "tok", mode: .invite) + XCTAssertEqual(url?.absoluteString, "https://interlinedlist.com/lists/invite/tok") + } + + func test_givenInviteURL_whenHandling_thenPostsInviteModeAndReturnsTrue() { + // The deep-link handler routes invites through the same notification, + // so `MainWindowView` needs no change. + var captured: ParsedShare? + let handled = ShareLinkDeepLink.handle( + URL(string: "https://interlinedlist.com/lists/invite/tok")!, + post: { captured = $0 } + ) + XCTAssertTrue(handled) + XCTAssertEqual(captured, ParsedShare(kind: .list, token: "tok", mode: .invite)) + } + // MARK: - handle() routing (posts only for share links) func test_givenShareURL_whenHandling_thenPostsParsedAndReturnsTrue() { diff --git a/AppTests/Support/StubListsService.swift b/AppTests/Support/StubListsService.swift index 68ced61..969acf6 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -31,9 +31,12 @@ struct RecordedListsCall: Sendable, Equatable { case deleteRow(listId: String, rowId: String) case watchers(listId: String) case myWatcherStatus(listId: String) - case watcherUsers(listId: String) + case watcherCandidates(listId: String, search: String?, limit: Int) case setWatcher(listId: String, userId: String, role: WatcherRole) + case addWatcher(listId: String, userId: String, role: WatcherRole, notify: Bool) case removeWatcher(listId: String, userId: String) + case watching(limit: Int, offset: Int) + case contributors(listId: String) case connections(listId: String?) case addConnection(from: String, to: String, label: String?) case removeConnection(id: String) @@ -58,7 +61,10 @@ actor StubListsService: ListsServicing { private var updateRowOutcomes: [Result] = [] private var deleteRowOutcomes: [Result] = [] private var watchersOutcomes: [Result<[ListWatcher], Error>] = [] - private var watcherUsersOutcomes: [Result<[ListWatcher], Error>] = [] + private var watcherCandidatesOutcomes: [Result<[CollaboratorCandidate], Error>] = [] + private var addWatcherOutcomes: [Result] = [] + private var watchingOutcomes: [Result] = [] + private var contributorsOutcomes: [Result<[ListContributor], Error>] = [] private var myWatcherStatusOutcomes: [Result] = [] private var setWatcherOutcomes: [Result] = [] private var removeWatcherOutcomes: [Result] = [] @@ -122,8 +128,15 @@ actor StubListsService: ListsServicing { func enqueueDeleteRow(failure error: Error) { deleteRowOutcomes.append(.failure(error)) } func enqueueWatchers(success watchers: [ListWatcher]) { watchersOutcomes.append(.success(watchers)) } - func enqueueWatcherUsers(success watchers: [ListWatcher]) { watcherUsersOutcomes.append(.success(watchers)) } - func enqueueWatcherUsers(failure error: Error) { watcherUsersOutcomes.append(.failure(error)) } + func enqueueWatchers(failure error: Error) { watchersOutcomes.append(.failure(error)) } + func enqueueWatcherCandidates(success candidates: [CollaboratorCandidate]) { watcherCandidatesOutcomes.append(.success(candidates)) } + func enqueueWatcherCandidates(failure error: Error) { watcherCandidatesOutcomes.append(.failure(error)) } + func enqueueAddWatcherSuccess() { addWatcherOutcomes.append(.success(())) } + func enqueueAddWatcher(failure error: Error) { addWatcherOutcomes.append(.failure(error)) } + func enqueueWatching(success page: WatchedListsPage) { watchingOutcomes.append(.success(page)) } + func enqueueWatching(failure error: Error) { watchingOutcomes.append(.failure(error)) } + func enqueueContributors(success contributors: [ListContributor]) { contributorsOutcomes.append(.success(contributors)) } + func enqueueContributors(failure error: Error) { contributorsOutcomes.append(.failure(error)) } func enqueueMyWatcherStatus(success status: WatcherStatus) { myWatcherStatusOutcomes.append(.success(status)) } func enqueueSetWatcher(success watcher: ListWatcher) { setWatcherOutcomes.append(.success(watcher)) } @@ -248,9 +261,26 @@ actor StubListsService: ListsServicing { return try take(&myWatcherStatusOutcomes, label: "myWatcherStatus") } - func watcherUsers(of listId: String) async throws -> [ListWatcher] { - recorded.append(.init(kind: .watcherUsers(listId: listId))) - return try take(&watcherUsersOutcomes, label: "watcherUsers") + func watcherCandidates(of listId: String, search: String?, limit: Int) async throws -> [CollaboratorCandidate] { + recorded.append(.init(kind: .watcherCandidates(listId: listId, search: search, limit: limit))) + return try take(&watcherCandidatesOutcomes, label: "watcherCandidates") + } + + func addWatcher(listId: String, userId: String, role: WatcherRole, notify: Bool) async throws { + recorded.append(.init(kind: .addWatcher(listId: listId, userId: userId, role: role, notify: notify))) + let _: Void = try take(&addWatcherOutcomes, label: "addWatcher") + } + + // MARK: ListsServicing — shared with me (G23) + + func watching(limit: Int, offset: Int) async throws -> WatchedListsPage { + recorded.append(.init(kind: .watching(limit: limit, offset: offset))) + return try take(&watchingOutcomes, label: "watching") + } + + func contributors(of listId: String) async throws -> [ListContributor] { + recorded.append(.init(kind: .contributors(listId: listId))) + return try take(&contributorsOutcomes, label: "contributors") } func setWatcher(listId: String, userId: String, role: WatcherRole) async throws -> ListWatcher { @@ -365,6 +395,62 @@ enum ListsFixtures { ListWatcher(userId: userId, username: username, role: role) } + // MARK: - G23 shared-with-me fixtures + + static func watchedList( + id: String, + title: String = "Shared List", + ownerUsername: String? = "adron", + ownerDisplayName: String? = "Adron Hall", + role: ShareRole = .collaborator, + parentTitle: String? = nil + ) -> WatchedList { + WatchedList( + list: ownedList(id: id, title: title), + owner: ListOwner( + id: "owner-\(id)", + username: ownerUsername, + displayName: ownerDisplayName + ), + role: role, + parentTitle: parentTitle + ) + } + + static func watchedListsPage( + _ lists: [WatchedList], + hasMore: Bool = false, + nextOffset: Int? = nil + ) -> WatchedListsPage { + WatchedListsPage(lists: lists, hasMore: hasMore, nextOffset: nextOffset) + } + + static func contributor( + id: String, + username: String? = "adron", + displayName: String? = "Adron Hall", + addedCount: Int = 0, + editedCount: Int = 0, + score: Int = 0 + ) -> ListContributor { + ListContributor( + id: id, + username: username, + displayName: displayName, + addedCount: addedCount, + editedCount: editedCount, + score: score + ) + } + + static func candidate( + id: String, + username: String? = "ada", + displayName: String? = "Ada Lovelace" + ) -> CollaboratorCandidate { + CollaboratorCandidate(id: id, username: username, displayName: displayName) + } + static func connection( id: String, from: String, diff --git a/AppTests/Support/StubSharingService.swift b/AppTests/Support/StubSharingService.swift index c1b0df9..0c55aff 100644 --- a/AppTests/Support/StubSharingService.swift +++ b/AppTests/Support/StubSharingService.swift @@ -22,6 +22,8 @@ struct RecordedSharingCall: Sendable, Equatable { case revokeListLink(listId: String, token: String) case resolveList(token: String) case claimList(token: String) + case sharedListRows(token: String, limit: Int, offset: Int) + case resolveListInvite(token: String) case documentLinks(documentId: String) case createDocumentLink(documentId: String, role: ShareRole, expiresAt: Date?) @@ -55,6 +57,8 @@ actor StubSharingService: SharingServicing { private var revokeOutcomes: [Result] = [] private var resolveOutcomes: [Result] = [] private var claimOutcomes: [Result] = [] + private var sharedRowsOutcomes: [Result] = [] + private var resolveInviteOutcomes: [Result] = [] // Collaborators. private var collaboratorsOutcomes: [Result<[Collaborator], Error>] = [] @@ -87,6 +91,12 @@ actor StubSharingService: SharingServicing { func enqueueClaim(success value: ShareClaim) { claimOutcomes.append(.success(value)) } func enqueueClaim(failure error: Error) { claimOutcomes.append(.failure(error)) } + func enqueueSharedRows(success value: RowsPage) { sharedRowsOutcomes.append(.success(value)) } + func enqueueSharedRows(failure error: Error) { sharedRowsOutcomes.append(.failure(error)) } + + func enqueueResolveInvite(success value: ResolvedListInvite) { resolveInviteOutcomes.append(.success(value)) } + func enqueueResolveInvite(failure error: Error) { resolveInviteOutcomes.append(.failure(error)) } + func enqueueCollaborators(success value: [Collaborator]) { collaboratorsOutcomes.append(.success(value)) } func enqueueCollaborators(failure error: Error) { collaboratorsOutcomes.append(.failure(error)) } @@ -138,6 +148,16 @@ actor StubSharingService: SharingServicing { return try take(&claimOutcomes, label: "claimListShare") } + func sharedListRows(token: String, limit: Int, offset: Int) async throws -> RowsPage { + recorded.append(.init(kind: .sharedListRows(token: token, limit: limit, offset: offset))) + return try take(&sharedRowsOutcomes, label: "sharedListRows") + } + + func resolveListInvite(token: String) async throws -> ResolvedListInvite { + recorded.append(.init(kind: .resolveListInvite(token: token))) + return try take(&resolveInviteOutcomes, label: "resolveListInvite") + } + // MARK: SharingServicing — Documents func documentShareLinks(documentId: String) async throws -> [ShareLink] { diff --git a/AppTests/WatchedListsViewModelTests.swift b/AppTests/WatchedListsViewModelTests.swift new file mode 100644 index 0000000..402ba3c --- /dev/null +++ b/AppTests/WatchedListsViewModelTests.swift @@ -0,0 +1,252 @@ +// WatchedListsViewModelTests +// +// BDD-named tests for the "Shared with me" sidebar section +// (work-consolidation.md G23 / issue #48). +// +// The load-failure case carries the issue's acceptance criterion directly: +// this view model owns its own error, so a failing `GET /api/lists/watching` +// cannot blank or block the owned-lists section beside it. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class WatchedListsViewModelTests: XCTestCase { + + // MARK: - load + + func test_givenSharedLists_whenLoading_thenPopulatesInServerOrder() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1", title: "Shows", role: .collaborator), + ListsFixtures.watchedList(id: "L2", title: "Videos", role: .watcher) + ])) + let viewModel = WatchedListsViewModel(lists: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.watched.map(\.id), ["L1", "L2"]) + XCTAssertNil(viewModel.error) + XCTAssertTrue(viewModel.hasLoadedOnce) + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .watching(limit: WatchedListsViewModel.pageSize, offset: 0)) + } + + func test_givenNothingShared_whenLoading_thenLeavesEmptyWithoutError() async { + // Boundary: zero watched lists is a normal state, not a failure — the + // sidebar section renders nothing rather than an empty-state row. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([])) + let viewModel = WatchedListsViewModel(lists: stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.watched.isEmpty) + XCTAssertNil(viewModel.error) + XCTAssertTrue(viewModel.hasLoadedOnce) + } + + func test_givenWatchingFails_whenLoading_thenSurfacesErrorScopedToThisSection() async { + // Upstream failure: the error lands here, on the shared-with-me view + // model only. The owned-lists sidebar has its own model and its own + // state, so it keeps rendering (issue #48 acceptance criteria). + let stub = StubListsService() + await stub.enqueueWatching(failure: TestError.upstream("watching-500")) + let viewModel = WatchedListsViewModel(lists: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.error as? TestError, .upstream("watching-500")) + XCTAssertTrue(viewModel.watched.isEmpty) + } + + func test_givenLoadedListsThenFailure_whenReloading_thenKeepsExistingRows() async { + // A flaky refresh must not cost the user their shared lists. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1") + ])) + await stub.enqueueWatching(failure: TestError.upstream("flaky")) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + await viewModel.load() + + XCTAssertEqual(viewModel.watched.map(\.id), ["L1"]) + XCTAssertEqual(viewModel.error as? TestError, .upstream("flaky")) + } + + // MARK: - grouping + + func test_givenMixedRoles_whenGrouping_thenOrdersByRoleAndOmitsEmptyGroups() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1", role: .collaborator), + ListsFixtures.watchedList(id: "L2", role: .watcher), + ListsFixtures.watchedList(id: "L3", role: .collaborator) + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + let groups = viewModel.groupedByRole + + // `manager` has no rows, so it is absent entirely. + XCTAssertEqual(groups.map(\.role), [.watcher, .collaborator]) + XCTAssertEqual(groups.first(where: { $0.role == .collaborator })?.lists.map(\.id), ["L1", "L3"]) + } + + func test_givenNoLists_whenGrouping_thenReturnsNoGroups() async { + // Boundary for the grouping helper on its own. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + XCTAssertTrue(viewModel.groupedByRole.isEmpty) + } + + // MARK: - selection lookup + + func test_givenLoadedLists_whenLookingUpKnownID_thenReturnsEntry() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1", title: "Shows") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + XCTAssertEqual(viewModel.list(withID: "L1")?.title, "Shows") + } + + func test_givenUnknownOrNilID_whenLookingUp_thenReturnsNil() async { + // Invalid input: an owned-list id (or no selection) must miss here so + // the rows pane falls through to the owned collection. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + XCTAssertNil(viewModel.list(withID: "OWNED-1")) + XCTAssertNil(viewModel.list(withID: nil)) + } + + // MARK: - paging + + func test_givenMorePages_whenLoadingMore_thenAppends() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage( + [ListsFixtures.watchedList(id: "L1")], + hasMore: true, + nextOffset: 50 + )) + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L2") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + await viewModel.loadMore() + + XCTAssertEqual(viewModel.watched.map(\.id), ["L1", "L2"]) + XCTAssertFalse(viewModel.hasMore) + } + + func test_givenNoMorePages_whenLoadingMore_thenIsNoop() async { + // Boundary: `loadMore` on a complete list must not spend a request. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + let callsBefore = await stub.recorded.count + + await viewModel.loadMore() + + let callsAfter = await stub.recorded.count + XCTAssertEqual(callsAfter, callsBefore) + } + + // MARK: - freshness TTL + + func test_givenNeverLoaded_whenAskingShouldRefresh_thenIsTrue() { + let viewModel = WatchedListsViewModel(lists: StubListsService()) + + XCTAssertTrue(viewModel.shouldRefresh) + } + + func test_givenJustLoaded_whenAskingShouldRefresh_thenIsFalse() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([])) + let viewModel = WatchedListsViewModel(lists: stub) + + await viewModel.load() + + XCTAssertFalse(viewModel.shouldRefresh) + } + + // MARK: - apply(event:) + + func test_givenListDeletedEvent_whenApplied_thenDropsTheRow() async { + // Losing access (or the owner deleting the list) must drop it. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1"), + ListsFixtures.watchedList(id: "L2") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + viewModel.apply(event: .listDeleted(id: "L1")) + + XCTAssertEqual(viewModel.watched.map(\.id), ["L2"]) + } + + func test_givenListCreatedEvent_whenApplied_thenIsNoop() async { + // A list *this* account creates belongs to the owned section, never + // to shared-with-me. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + viewModel.apply(event: .listCreated(ListsFixtures.ownedList(id: "NEW"))) + + XCTAssertEqual(viewModel.watched.map(\.id), ["L1"]) + } + + func test_givenListUpdatedEventForWatchedList_whenApplied_thenKeepsOwnerAndRole() async { + // The event carries only the list, so the owner and the caller's role + // must survive the swap. + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1", title: "Old", role: .collaborator) + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + viewModel.apply(event: .listUpdated(ListsFixtures.ownedList(id: "L1", title: "New"))) + + XCTAssertEqual(viewModel.watched.first?.title, "New") + XCTAssertEqual(viewModel.watched.first?.role, .collaborator) + XCTAssertEqual(viewModel.watched.first?.owner?.username, "adron") + } + + func test_givenEventForUnknownList_whenApplied_thenIsNoop() async { + let stub = StubListsService() + await stub.enqueueWatching(success: ListsFixtures.watchedListsPage([ + ListsFixtures.watchedList(id: "L1", title: "Shows") + ])) + let viewModel = WatchedListsViewModel(lists: stub) + await viewModel.load() + + viewModel.apply(event: .listUpdated(ListsFixtures.ownedList(id: "OTHER", title: "Nope"))) + + XCTAssertEqual(viewModel.watched.map(\.title), ["Shows"]) + } +} diff --git a/AppTests/WatchersViewModelTests.swift b/AppTests/WatchersViewModelTests.swift index e6cef94..1f52fbd 100644 --- a/AppTests/WatchersViewModelTests.swift +++ b/AppTests/WatchersViewModelTests.swift @@ -1,6 +1,14 @@ // WatchersViewModelTests // -// BDD-named tests for the M3 sharing panel view model. +// BDD-named tests for the M3 sharing panel view model, updated for +// work-consolidation.md G23 (issue #48): +// +// • `load()` now reads `watchers(of:)`, the real access list, instead of +// `watcherUsers(of:)`, which was a candidate search all along. +// • Adding a person calls the real `addWatcher` (POST) rather than +// re-purposing the role-change PUT, and a subscriber block raises the +// upsell instead of an error banner. +// • Picking someone goes through `watcherCandidates(of:search:limit:)`. import XCTest import InterlinedDomain @@ -9,24 +17,34 @@ import InterlinedDomain @MainActor final class WatchersViewModelTests: XCTestCase { + private func makeViewModel( + stub: StubListsService, + eventBus: ListsEventBus = ListsEventBus() + ) -> WatchersViewModel { + WatchersViewModel(lists: stub, eventBus: eventBus, listId: "L1") + } + // MARK: - load func test_givenLoadedWatchers_whenLoading_thenPopulates() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1", username: "alice", role: .editor) - await stub.enqueueWatcherUsers(success: [alice]) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: [alice]) + let viewModel = makeViewModel(stub: stub) await viewModel.load() XCTAssertEqual(viewModel.watchers.map(\.userId), ["u1"]) XCTAssertNil(viewModel.error) + // The panel must read the access list, not the candidate search. + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .watchers(listId: "L1")) } func test_givenEmptyResponse_whenLoading_thenLeavesEmpty() async { let stub = StubListsService() - await stub.enqueueWatcherUsers(success: []) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: []) + let viewModel = makeViewModel(stub: stub) await viewModel.load() @@ -35,8 +53,8 @@ final class WatchersViewModelTests: XCTestCase { func test_givenAPIFailure_whenLoading_thenSurfacesError() async { let stub = StubListsService() - await stub.enqueueWatcherUsers(failure: TestError.upstream("denied")) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(failure: TestError.upstream("denied")) + let viewModel = makeViewModel(stub: stub) await viewModel.load() @@ -45,27 +63,29 @@ final class WatchersViewModelTests: XCTestCase { // MARK: - setRole optimistic + rollback - func test_givenRoleChange_whenSetRoleSucceeds_thenReplacesWithServerCopy() async { + func test_givenRoleChange_whenSetRoleSucceeds_thenAppliesServerRole() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1", username: "alice", role: .viewer) - await stub.enqueueWatcherUsers(success: [alice]) + await stub.enqueueWatchers(success: [alice]) let confirmed = ListsFixtures.watcher(userId: "u1", username: "alice", role: .editor) await stub.enqueueSetWatcher(success: confirmed) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + let viewModel = makeViewModel(stub: stub) await viewModel.load() await viewModel.setRole(userId: "u1", role: .editor) XCTAssertEqual(viewModel.watchers.first?.role, .editor) + // Display fields survive: the route only answers `{ role }`. + XCTAssertEqual(viewModel.watchers.first?.username, "alice") } func test_givenSetRoleFailure_whenSetRole_thenRollsBack() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1", username: "alice", role: .viewer) - await stub.enqueueWatcherUsers(success: [alice]) + await stub.enqueueWatchers(success: [alice]) let failure = TestError.upstream("server-down") await stub.enqueueSetWatcher(failure: failure) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + let viewModel = makeViewModel(stub: stub) await viewModel.load() await viewModel.setRole(userId: "u1", role: .editor) @@ -74,14 +94,31 @@ final class WatchersViewModelTests: XCTestCase { XCTAssertEqual(viewModel.error as? TestError, failure) } + func test_givenSetRoleBlockedForFreeAccount_whenSetRole_thenRollsBackAndRaisesUpsell() async { + // Upstream-failure variant: a 403 arrives as `subscriberRequired` and + // must present as an upsell, never as a raw error banner. + let stub = StubListsService() + let alice = ListsFixtures.watcher(userId: "u1", username: "alice", role: .viewer) + await stub.enqueueWatchers(success: [alice]) + await stub.enqueueSetWatcher(failure: ListsError.subscriberRequired) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.setRole(userId: "u1", role: .editor) + + XCTAssertEqual(viewModel.watchers.first?.role, .viewer) + XCTAssertTrue(viewModel.showSubscriberUpsell) + XCTAssertNil(viewModel.error) + } + // MARK: - remove func test_givenWatcher_whenRemoveSucceeds_thenDropsFromList() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1") - await stub.enqueueWatcherUsers(success: [alice]) + await stub.enqueueWatchers(success: [alice]) await stub.enqueueRemoveWatcherSuccess() - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + let viewModel = makeViewModel(stub: stub) await viewModel.load() await viewModel.remove(userId: "u1") @@ -92,9 +129,9 @@ final class WatchersViewModelTests: XCTestCase { func test_givenRemoveFailure_whenRemoving_thenRestoresSnapshot() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1") - await stub.enqueueWatcherUsers(success: [alice]) + await stub.enqueueWatchers(success: [alice]) await stub.enqueueRemoveWatcher(failure: TestError.upstream("denied")) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + let viewModel = makeViewModel(stub: stub) await viewModel.load() await viewModel.remove(userId: "u1") @@ -107,8 +144,8 @@ final class WatchersViewModelTests: XCTestCase { func test_givenEventForOtherList_whenApplied_thenIsNoop() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1", role: .viewer) - await stub.enqueueWatcherUsers(success: [alice]) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: [alice]) + let viewModel = makeViewModel(stub: stub) await viewModel.load() viewModel.apply(event: .watcherChanged( @@ -122,8 +159,8 @@ final class WatchersViewModelTests: XCTestCase { func test_givenWatcherChangedEventForList_whenApplied_thenSwapsRole() async { let stub = StubListsService() let alice = ListsFixtures.watcher(userId: "u1", role: .viewer) - await stub.enqueueWatcherUsers(success: [alice]) - let viewModel = WatchersViewModel(lists: stub, userService: StubUserService(), eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: [alice]) + let viewModel = makeViewModel(stub: stub) await viewModel.load() viewModel.apply(event: .watcherChanged( @@ -134,68 +171,138 @@ final class WatchersViewModelTests: XCTestCase { XCTAssertEqual(viewModel.watchers.first?.role, .owner) } - // MARK: - lookupAndAdd (NW-1) + // MARK: - candidate search (G23) + + func test_givenMatchingPeople_whenSearchingCandidates_thenPopulatesCandidates() async { + let stub = StubListsService() + await stub.enqueueWatchers(success: []) + await stub.enqueueWatcherCandidates(success: [ListsFixtures.candidate(id: "u9")]) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.searchCandidates(query: "ada") - func test_givenValidHandle_whenLookupAndAdd_thenPopulatesFoundUser() async { + XCTAssertEqual(viewModel.candidates.map(\.id), ["u9"]) + XCTAssertTrue(viewModel.hasSearchedOnce) + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .watcherCandidates(listId: "L1", search: "ada", limit: WatchersViewModel.candidatePageSize) + ) + } + + func test_givenNobodyMatches_whenSearchingCandidates_thenLeavesCandidatesEmpty() async { + // Boundary: the search resolved, it just matched nobody. The sheet + // reads `hasSearchedOnce` to say so instead of "search for someone". let stub = StubListsService() - let users = StubUserService() - await stub.enqueueWatcherUsers(success: []) - users.enqueueLookupUser(success: UserSearchResult(id: "u9", username: "new-user")) - let viewModel = WatchersViewModel(lists: stub, userService: users, eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: []) + await stub.enqueueWatcherCandidates(success: []) + let viewModel = makeViewModel(stub: stub) await viewModel.load() - await viewModel.lookupAndAdd(handle: "new-user") + await viewModel.searchCandidates(query: "nobody") - XCTAssertEqual(viewModel.foundUser?.id, "u9") + XCTAssertTrue(viewModel.candidates.isEmpty) + XCTAssertTrue(viewModel.hasSearchedOnce) XCTAssertNil(viewModel.error) } - func test_givenUnknownHandle_whenLookupAndAdd_thenSetsNotFoundError() async { + func test_givenCandidateSearchFails_whenSearching_thenClearsCandidatesAndSurfacesError() async { + let stub = StubListsService() + await stub.enqueueWatchers(success: []) + await stub.enqueueWatcherCandidates(success: [ListsFixtures.candidate(id: "u9")]) + await stub.enqueueWatcherCandidates(failure: TestError.upstream("boom")) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + await viewModel.searchCandidates(query: "ada") + + await viewModel.searchCandidates(query: "ada") + + XCTAssertTrue(viewModel.candidates.isEmpty) + XCTAssertEqual(viewModel.error as? TestError, .upstream("boom")) + } + + // MARK: - addWatcher (G23) + + func test_givenCandidate_whenAddWatcherSucceeds_thenAppendsAndDropsCandidate() async { let stub = StubListsService() - let users = StubUserService() - await stub.enqueueWatcherUsers(success: []) - users.enqueueLookupUser(success: nil) - let viewModel = WatchersViewModel(lists: stub, userService: users, eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: []) + await stub.enqueueWatcherCandidates(success: [ListsFixtures.candidate(id: "u9", username: "new-user")]) + await stub.enqueueAddWatcherSuccess() + let viewModel = makeViewModel(stub: stub) await viewModel.load() + await viewModel.searchCandidates(query: "new") - await viewModel.lookupAndAdd(handle: "ghost") + await viewModel.addWatcher(candidate: ListsFixtures.candidate(id: "u9", username: "new-user"), role: .viewer) - XCTAssertNil(viewModel.foundUser) - XCTAssertEqual(viewModel.error as? WatchersError, .userNotFound("ghost")) + XCTAssertEqual(viewModel.watchers.map(\.userId), ["u9"]) + XCTAssertEqual(viewModel.watchers.first?.role, .viewer) + XCTAssertTrue(viewModel.candidates.isEmpty) + // The POST route is the one that gets hit, with the notify default. + let recorded = await stub.recorded + XCTAssertEqual( + recorded.last?.kind, + .addWatcher(listId: "L1", userId: "u9", role: .viewer, notify: true) + ) } - func test_givenFoundUser_whenAddWatcherSucceeds_thenAppendsConfirmedWatcher() async { + func test_givenAlreadyAWatcher_whenAddingAgain_thenRejectsWithoutCallingService() async { + // Invalid-input member: the guard fires before any service call. let stub = StubListsService() - let users = StubUserService() - await stub.enqueueWatcherUsers(success: []) - users.enqueueLookupUser(success: UserSearchResult(id: "u9", username: "new-user")) - let confirmed = ListsFixtures.watcher(userId: "u9", username: "new-user", role: .viewer) - await stub.enqueueSetWatcher(success: confirmed) - let viewModel = WatchersViewModel(lists: stub, userService: users, eventBus: ListsEventBus(), listId: "L1") + let existing = ListsFixtures.watcher(userId: "u9", username: "new-user", role: .viewer) + await stub.enqueueWatchers(success: [existing]) + let viewModel = makeViewModel(stub: stub) await viewModel.load() - await viewModel.lookupAndAdd(handle: "new-user") + let callsBefore = await stub.recorded.count - await viewModel.addWatcher(userId: "u9", role: .viewer) + await viewModel.addWatcher(candidate: ListsFixtures.candidate(id: "u9"), role: .editor) - XCTAssertEqual(viewModel.watchers.first?.userId, "u9") - XCTAssertNil(viewModel.foundUser) + XCTAssertEqual(viewModel.error as? WatchersError, .alreadyWatcher) + XCTAssertEqual(viewModel.watchers.map(\.userId), ["u9"]) + let callsAfter = await stub.recorded.count + XCTAssertEqual(callsAfter, callsBefore, "No service call may be made for a duplicate add.") } func test_givenAddWatcherFails_whenAddingWatcher_thenRollsBackSnapshot() async { let stub = StubListsService() - let users = StubUserService() let existing = ListsFixtures.watcher(userId: "u1", username: "alice", role: .viewer) - await stub.enqueueWatcherUsers(success: [existing]) - users.enqueueLookupUser(success: UserSearchResult(id: "u9", username: "new-user")) - await stub.enqueueSetWatcher(failure: TestError.upstream("boom")) - let viewModel = WatchersViewModel(lists: stub, userService: users, eventBus: ListsEventBus(), listId: "L1") + await stub.enqueueWatchers(success: [existing]) + await stub.enqueueAddWatcher(failure: TestError.upstream("boom")) + let viewModel = makeViewModel(stub: stub) await viewModel.load() - await viewModel.lookupAndAdd(handle: "new-user") - await viewModel.addWatcher(userId: "u9", role: .viewer) + await viewModel.addWatcher(candidate: ListsFixtures.candidate(id: "u9"), role: .viewer) - // Rollback: only the original watcher remains. XCTAssertEqual(viewModel.watchers.map(\.userId), ["u1"]) XCTAssertNotNil(viewModel.error) } + + func test_givenFreeAccount_whenAddingWatcher_thenRollsBackAndRaisesUpsell() async { + // Upstream-failure member: the subscriber gate is a 403 the service + // projects onto `ListsError.subscriberRequired`. + let stub = StubListsService() + await stub.enqueueWatchers(success: []) + await stub.enqueueAddWatcher(failure: ListsError.subscriberRequired) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + + await viewModel.addWatcher(candidate: ListsFixtures.candidate(id: "u9"), role: .editor) + + XCTAssertTrue(viewModel.watchers.isEmpty, "The provisional row must be rolled back.") + XCTAssertTrue(viewModel.showSubscriberUpsell) + XCTAssertNil(viewModel.error, "The gate presents as an upsell, not an error.") + } + + func test_givenUpsellShown_whenDismissed_thenClearsFlag() async { + let stub = StubListsService() + await stub.enqueueWatchers(success: []) + await stub.enqueueAddWatcher(failure: ListsError.subscriberRequired) + let viewModel = makeViewModel(stub: stub) + await viewModel.load() + await viewModel.addWatcher(candidate: ListsFixtures.candidate(id: "u9"), role: .editor) + + viewModel.dismissSubscriberUpsell() + + XCTAssertFalse(viewModel.showSubscriberUpsell) + } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OwnedListMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OwnedListMappers.swift index bc3f133..a0a12e6 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OwnedListMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/OwnedListMappers.swift @@ -46,14 +46,20 @@ extension OwnedListsPage { // MARK: - Watcher mapping extension ListWatcher { - /// Maps a watcher DTO. Missing role tokens collapse to `.viewer` so the - /// share-sheet always has a renderable role; unknown tokens preserve - /// under `.other` per `WatcherRole.init(wireToken:)`. + /// Maps a watcher DTO. Missing role tokens collapse to `.viewer` — the + /// least privileged role — so the share-sheet always has a renderable role + /// and an unknown taxonomy never unlocks edit affordances; unknown tokens + /// preserve under `.other` per `WatcherRole.init(wireToken:)`. + /// + /// The live route nests the person under `user`; the flat `username` is + /// read as a fallback so older fixtures still map (work-consolidation.md G23). public init(from dto: ListWatcherDTO) { let role = dto.role.map(WatcherRole.init(wireToken:)) ?? .viewer self.init( userId: dto.userId, - username: dto.username, + username: dto.user?.username ?? dto.username, + displayName: dto.user?.displayName, + avatarURL: dto.user?.avatar.flatMap(URL.init(string:)), role: role, createdAt: dto.createdAt ) diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift index dbbbe0f..2bdce29 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift @@ -13,11 +13,15 @@ public enum ShareRole: String, Sendable, Equatable, Hashable, CaseIterable, Iden public var id: String { rawValue } - /// UI-facing label. + /// UI-facing label, matching the web's vocabulary on `/help/lists` + /// (Read-only → `watcher`, Edit → `collaborator`, Admin → `manager`). + /// Kept identical to `WatcherRole.label` so the sharing dialog, the + /// watchers panel, and the shared-with-me sidebar all say the same words + /// (work-consolidation.md G23). public var label: String { switch self { - case .watcher: return "Viewer" - case .collaborator: return "Editor" + case .watcher: return "Read-only" + case .collaborator: return "Edit" case .manager: return "Admin" } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatchedList.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatchedList.swift new file mode 100644 index 0000000..dd440a6 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatchedList.swift @@ -0,0 +1,275 @@ +import Foundation +import InterlinedKit + +// MARK: - ListOwner + +/// The person who owns a list someone else can see — the `user` object the +/// `/api/lists/watching` rows embed (work-consolidation.md G23). +/// +/// Deliberately thinner than `UserSummary`: the watching route returns only +/// `id` / `username` / `displayName`, and modelling more would invite the UI +/// to render fields that are always `nil`. +public struct ListOwner: Sendable, Equatable, Hashable, Identifiable { + + public let id: String + public let username: String? + public let displayName: String? + public let avatarURL: URL? + + /// Best available human label: display name, then `@username`, then a + /// neutral fallback so a row never renders blank. + public var displayLabel: String { + if let displayName, !displayName.isEmpty { return displayName } + if let username, !username.isEmpty { return username } + return "Someone" + } + + public init( + id: String, + username: String? = nil, + displayName: String? = nil, + avatarURL: URL? = nil + ) { + self.id = id + self.username = username + self.displayName = displayName + self.avatarURL = avatarURL + } +} + +// MARK: - WatchedList + +/// A list **someone else owns** that the signed-in account has been granted +/// access to — the "shared with me" projection behind `GET /api/lists/watching` +/// (work-consolidation.md G23 / issue #48). +/// +/// Composition over a second list type: the list itself is the existing +/// `OwnedList` (the wire rows are a strict superset of the owned-list shape), +/// and this type adds only what is genuinely new — who owns it, what the +/// caller may do with it, and the parent's title. +public struct WatchedList: Sendable, Equatable, Hashable, Identifiable { + + /// The list itself. Same type the owned-list sidebar renders, so a watched + /// list drops straight into the existing rows/detail views. + public let list: OwnedList + + /// The list's owner. `nil` when the route omitted the `user` object. + public let owner: ListOwner? + + /// The **caller's** role on this list — what *you* may do, not what the + /// owner may do. + public let role: ShareRole + + /// The parent list's title, when the row carried a `parent` projection. + /// + /// - Important: a parent projection does **not** imply access to the + /// parent. A list can be shared without its parent being shared, so this + /// is a breadcrumb label only — never navigate off it. + public let parentTitle: String? + + public var id: String { list.id } + + /// Convenience passthrough so sidebar rows can read `watched.title`. + public var title: String { list.title } + + /// Whether the caller may change this list's rows. Read-only shares + /// (`watcher`) must not render edit affordances. + public var canEdit: Bool { role.canEdit } + + public init( + list: OwnedList, + owner: ListOwner? = nil, + role: ShareRole, + parentTitle: String? = nil + ) { + self.list = list + self.owner = owner + self.role = role + self.parentTitle = parentTitle + } +} + +/// One page of watched lists — the same `*Page` shape every paginated read in +/// the domain layer uses (`OwnedListsPage`, `RowsPage`, `TimelinePage`). +public struct WatchedListsPage: Sendable, Equatable { + + public let lists: [WatchedList] + public let hasMore: Bool + public let nextOffset: Int? + + public init(lists: [WatchedList], hasMore: Bool, nextOffset: Int?) { + self.lists = lists + self.hasMore = hasMore + self.nextOffset = nextOffset + } + + /// The empty-page boundary value — used when nobody has shared a list. + public static let empty = WatchedListsPage(lists: [], hasMore: false, nextOffset: nil) +} + +// MARK: - ListContributor + +/// One ranked contributor to a list (`GET /api/lists/[id]/contributors`). +/// +/// The server ranks by an opaque `score`; the client preserves the server's +/// order rather than re-sorting, so a future change to the ranking formula +/// does not need a client release. +public struct ListContributor: Sendable, Equatable, Hashable, Identifiable { + + public let id: String + public let username: String? + public let displayName: String? + public let avatarURL: URL? + /// Rows this person added. `0` when the route omits the count. + public let addedCount: Int + /// Rows this person edited. `0` when the route omits the count. + public let editedCount: Int + /// The server's ranking score. Opaque — displayed, never recomputed. + public let score: Int + + /// Best available human label, matching `ListOwner.displayLabel`. + public var displayLabel: String { + if let displayName, !displayName.isEmpty { return displayName } + if let username, !username.isEmpty { return username } + return "Contributor" + } + + public init( + id: String, + username: String? = nil, + displayName: String? = nil, + avatarURL: URL? = nil, + addedCount: Int = 0, + editedCount: Int = 0, + score: Int = 0 + ) { + self.id = id + self.username = username + self.displayName = displayName + self.avatarURL = avatarURL + self.addedCount = addedCount + self.editedCount = editedCount + self.score = score + } +} + +// MARK: - ResolvedListInvite + +/// The landing payload for an email invite (`GET /api/lists/invite/{token}`). +/// +/// **The client cannot accept an invite.** `POST /api/lists/invite/{token}` is +/// declared `x-auth-type: session` in the live OpenAPI spec, so a Bearer-only +/// macOS client is locked out of the claim half. This type therefore backs a +/// *landing* experience only — show what the invite grants and hand the accept +/// step to the browser. Accepting is documented as always free, so no +/// entitlement gate belongs anywhere near it. +public struct ResolvedListInvite: Sendable, Equatable { + + /// The role the invite grants. + public let role: ShareRole + + /// `true` when nobody is signed in — the landing prompts sign-in. + public let needsAuth: Bool + + /// `true` when the signed-in account's verified email matches the invited + /// address. Even then the claim must happen in the browser. + public let canClaim: Bool + + /// `true` when someone is signed in under a *different* email than the one + /// invited. The invited address is never returned, so this flag is the only + /// signal the client gets. + public let wrongAccount: Bool + + /// `true` once the invite has already been claimed. + public let accepted: Bool + + /// The title of the list the invite grants access to. + public let resourceTitle: String? + + public init( + role: ShareRole, + needsAuth: Bool, + canClaim: Bool, + wrongAccount: Bool, + accepted: Bool, + resourceTitle: String? + ) { + self.role = role + self.needsAuth = needsAuth + self.canClaim = canClaim + self.wrongAccount = wrongAccount + self.accepted = accepted + self.resourceTitle = resourceTitle + } +} + +// MARK: - DTO → domain mapping + +extension ListOwner { + public init(from dto: ListUserDTO) { + self.init( + id: dto.id, + username: dto.username, + displayName: dto.displayName, + avatarURL: dto.avatar.flatMap(URL.init(string:)) + ) + } +} + +extension WatchedList { + /// Maps one `/api/lists/watching` row. + /// + /// An absent or unrecognised `role` collapses to `.watcher` — the least + /// privileged role — so a taxonomy the client does not know can never + /// unlock edit affordances. + public init(from dto: ListDTO) { + self.init( + list: OwnedList(from: dto), + owner: dto.user.map(ListOwner.init(from:)), + role: dto.role.flatMap(ShareRole.init(rawValue:)) ?? .watcher, + parentTitle: dto.parent?.title + ) + } +} + +extension WatchedListsPage { + /// Builds a page from the kit's `Paginated` envelope. + public init(from paginated: Paginated) { + let info = paginated.pagination + self.init( + lists: paginated.items.map(WatchedList.init(from:)), + hasMore: info.hasMore, + nextOffset: info.hasMore ? info.offset + info.limit : nil + ) + } +} + +extension ListContributor { + public init(from dto: ListContributorDTO) { + self.init( + id: dto.id, + username: dto.username, + displayName: dto.displayName, + avatarURL: dto.avatar.flatMap(URL.init(string:)), + addedCount: dto.addedCount ?? 0, + editedCount: dto.editedCount ?? 0, + score: dto.score ?? 0 + ) + } +} + +extension ResolvedListInvite { + /// Maps the landing payload. Every flag defaults to the conservative + /// value: an omitted `canClaim` means "not claimable", an omitted + /// `accepted` means "still open". + public init(from dto: ResolvedListInviteDTO) { + self.init( + role: dto.role.flatMap(ShareRole.init(rawValue:)) ?? .watcher, + needsAuth: dto.needsAuth ?? false, + canClaim: dto.canClaim ?? false, + wrongAccount: dto.wrongAccount ?? false, + accepted: dto.accepted ?? false, + resourceTitle: dto.resourceTitle + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatcherRole.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatcherRole.swift index f58fc80..c25e8e3 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatcherRole.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/WatcherRole.swift @@ -3,49 +3,69 @@ import Foundation /// Role granted to a watcher on a shared list (PLAN.md §1 "List sharing", §6 /// M3). /// -/// **Assumption (not yet documented upstream).** The role taxonomy for -/// `PUT /api/lists/[id]/watchers/[userId]` is not enumerated in the API docs -/// — `/API-backend-prompts-to-build.md` item 1.2 records this gap and proposes -/// `owner | editor | viewer` as the working set. The M3 share-sheet picker -/// renders against that set; if the upstream taxonomy lands with a different -/// shape, this enum is the one place to update. +/// **The taxonomy is now documented.** `/help/api/lists` states: "A watcher's +/// role is one of `watcher`, `collaborator`, or `manager`", and an invalid or +/// missing `role` on `PUT /api/lists/:id/watchers/:userId` is a `400`. The +/// three cases below are the client-side names for exactly those three roles; +/// `wireToken` emits the documented strings. +/// +/// - Warning: before work-consolidation.md G23 this enum emitted +/// `owner` / `editor` / `viewer` as wire tokens — none of which the API +/// accepts — so every role change was rejected with a `400`. The case names +/// are kept (the pickers and `.other` fallback depend on them); only the +/// wire mapping and the UI labels moved onto the documented vocabulary. /// /// `other(String)` preserves any unrecognised wire string so an unexpected /// role round-trips through the UI rather than crashing a switch. -public enum WatcherRole: Sendable, Equatable, Hashable { +public enum WatcherRole: Sendable, Equatable, Hashable, CaseIterable { - /// Full control. By the working assumption: read, edit rows, edit schema, - /// manage watchers, delete the list. + /// Full control — the API's `manager`. Read, edit rows, edit schema, + /// manage watchers, delete the list. The web labels this **Admin**. case owner - /// Edit access. By the working assumption: read and edit rows; cannot - /// edit schema, manage watchers, or delete the list. + /// Edit access — the API's `collaborator`. Read and edit rows. The web + /// labels this **Edit**. case editor - /// Read-only access. By the working assumption: read rows. + /// Read-only access — the API's `watcher`. The web labels this + /// **Read-only**. case viewer /// A role token the client does not yet recognise. Treated as no-edit / /// no-share for safety; preserved for display. case other(String) - /// The canonical wire token for this role. The wire taxonomy is the - /// open question above — this aligns with the prompts-file proposal. + /// The three roles a picker offers. `.other` is a decode-tolerance case, + /// never something the user can choose, so it is excluded. + public static var allCases: [WatcherRole] { [.viewer, .editor, .owner] } + + /// The canonical wire token, per `/help/api/lists`. public var wireToken: String { switch self { - case .owner: return "owner" - case .editor: return "editor" - case .viewer: return "viewer" + case .owner: return "manager" + case .editor: return "collaborator" + case .viewer: return "watcher" case .other(let raw): return raw } } + /// UI-facing label, matching the web's vocabulary on `/help/lists` + /// (Read-only → `watcher`, Edit → `collaborator`, Admin → `manager`). + public var label: String { + switch self { + case .owner: return "Admin" + case .editor: return "Edit" + case .viewer: return "Read-only" + case .other(let raw): return raw.capitalized + } + } + /// Maps a wire string to a role, case-insensitively. Unknown tokens /// preserve their original casing under `.other`. public init(wireToken: String) { switch wireToken.lowercased() { - case "owner": self = .owner - case "editor", "collaborator", "manager": self = .editor + case "owner", "manager": self = .owner + case "editor", "collaborator": self = .editor case "viewer", "watcher", "reader": self = .viewer default: self = .other(wireToken) } @@ -59,12 +79,17 @@ public struct ListWatcher: Sendable, Equatable, Hashable, Identifiable { /// The watching user's id. Identity for `Identifiable`. public let userId: String - /// The watching user's username when the API includes it on this row - /// (`/watchers/users` returns it; the per-list `/watchers` may omit it). + /// The watching user's username, read from the row's nested `user` object + /// (with the older flat `username` field as a fallback). public let username: String? - /// The watcher's role on this list. See `WatcherRole` for the taxonomy - /// assumption. + /// The watching user's display name, when the nested `user` carries one. + public let displayName: String? + + /// The watching user's avatar, when the nested `user` carries one. + public let avatarURL: URL? + + /// The watcher's role on this list. See `WatcherRole`. public let role: WatcherRole /// When the watcher was added, when the API includes the timestamp. @@ -72,14 +97,26 @@ public struct ListWatcher: Sendable, Equatable, Hashable, Identifiable { public var id: String { userId } + /// Best available human label: display name, then username, then a neutral + /// fallback so a row never renders blank. + public var displayLabel: String { + if let displayName, !displayName.isEmpty { return displayName } + if let username, !username.isEmpty { return username } + return "User" + } + public init( userId: String, username: String? = nil, + displayName: String? = nil, + avatarURL: URL? = nil, role: WatcherRole, createdAt: Date? = nil ) { self.userId = userId self.username = username + self.displayName = displayName + self.avatarURL = avatarURL self.role = role self.createdAt = createdAt } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift index 351ae37..f48692e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -18,6 +18,11 @@ public enum ListsError: Error, Sendable, Equatable { /// is included so the editor can fall back to raw-text mode rather than /// silently surfacing an empty schema. case malformedSchema(raw: String, reason: SchemaDSLError) + + /// An add-watcher call named no user. Raised before any HTTP call because + /// the route treats a missing `userId` as "subscribe *me* to this list", + /// which is a different action entirely (work-consolidation.md G23). + case invalidWatcher } extension ListsError: LocalizedError, CustomStringConvertible { @@ -29,6 +34,8 @@ extension ListsError: LocalizedError, CustomStringConvertible { return "Managing lists requires an active subscription." case .malformedSchema(let raw, let reason): return "Schema \"\(raw)\" could not be parsed: \(reason.description)" + case .invalidWatcher: + return "Choose a person to share this list with." } } } @@ -150,18 +157,51 @@ public protocol ListsServicing: Sendable { /// Deletes a row. func deleteRow(listId: String, rowId: String) async throws + // MARK: - G23 shared with me + + /// Loads one page of lists **other people** shared with the caller + /// (`GET /api/lists/watching`). Distinct from `myLists`, which returns the + /// caller's own lists; the two collections never overlap. + func watching(limit: Int, offset: Int) async throws -> WatchedListsPage + + /// Loads a list's ranked contributors, in the server's ranking order. + /// Unpaged — the route returns every contributor. + func contributors(of listId: String) async throws -> [ListContributor] + + /// Grants `userId` access to `listId` at `role`. + /// + /// Subscriber-gated server-side: a free owner gets `403`, which this + /// method surfaces as `ListsError.subscriberRequired` so the UI can show + /// the upsell rather than a raw HTTP error. Pass `notify: false` to grant + /// access without emailing the recipient. + func addWatcher( + listId: String, + userId: String, + role: WatcherRole, + notify: Bool + ) async throws + // MARK: - M3 watchers - /// Loads every watcher on a list. + /// Loads every watcher on a list. Owner-only server-side. func watchers(of listId: String) async throws -> [ListWatcher] /// Loads the caller's own watcher status on a list. func myWatcherStatus(of listId: String) async throws -> WatcherStatus - /// Loads the watcher list with `username` populated. - func watcherUsers(of listId: String) async throws -> [ListWatcher] - - /// Grants or updates a watcher's role on a list. + /// Searches people the owner could add as watchers + /// (`GET /api/lists/[id]/watchers/users`). The server auto-excludes the + /// list's current watchers, so every result is addable. + /// + /// Note this is a **candidate** search, not the watcher list — use + /// `watchers(of:)` for who already has access. + func watcherCandidates( + of listId: String, + search: String?, + limit: Int + ) async throws -> [CollaboratorCandidate] + + /// Updates an existing watcher's role on a list. func setWatcher( listId: String, userId: String, @@ -438,12 +478,66 @@ public final class ListsService: ListsServicing { try await api.sendVoid(Lists.deleteRow(listId: listId, rowId: rowId)) } + // MARK: - G23 shared with me + + public func watching(limit: Int, offset: Int) async throws -> WatchedListsPage { + try requireListManagement() + let request = Lists.watching(limit: limit, offset: offset) + let (data, _) = try await api.sendRaw(request) + let key = request.paginationKey ?? "data" + let paginated = try PaginatedDecoder.decode( + ListDTO.self, + collectionKey: key, + from: data, + decoder: decoder + ) + // Deliberately *not* written through `store`: the owned-list cache is a + // single slice keyed under one domain, and folding watched lists into + // it would make them reappear in the owned sidebar on the next + // cache-first paint. + return WatchedListsPage(from: paginated) + } + + public func contributors(of listId: String) async throws -> [ListContributor] { + try requireListManagement() + let response = try await api.send(Lists.contributors(listId: listId)) + return response.contributors.map(ListContributor.init(from:)) + } + + public func addWatcher( + listId: String, + userId: String, + role: WatcherRole, + notify: Bool + ) async throws { + // Reject an empty id before spending a round-trip: an empty `userId` + // would silently flip the route into its *self-subscribe* branch + // (documented on /help/api/lists) and add the caller instead of the + // intended recipient. + let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUserId.isEmpty else { throw ListsError.invalidWatcher } + try requireListManagement() + let request = AddListWatcherRequest( + userId: trimmedUserId, + role: role.wireToken, + notify: notify + ) + do { + _ = try await api.send(Lists.addWatcher(listId: listId, request)) + } catch let error as APIError where error.httpStatusCode == 403 { + // The server is the real subscriber gate today — see the TODO on + // `requireListManagement()`. Project its 403 onto the domain error + // the sharing UI already renders as an upsell. + throw ListsError.subscriberRequired + } + } + // MARK: - M3 watchers public func watchers(of listId: String) async throws -> [ListWatcher] { try requireListManagement() - let dtos = try await api.send(Lists.watchers(listId: listId)) - return dtos.map(ListWatcher.init(from:)) + let response = try await api.send(Lists.watchers(listId: listId)) + return response.watchers.map(ListWatcher.init(from:)) } public func myWatcherStatus(of listId: String) async throws -> WatcherStatus { @@ -452,10 +546,31 @@ public final class ListsService: ListsServicing { return WatcherStatus(from: dto) } - public func watcherUsers(of listId: String) async throws -> [ListWatcher] { + public func watcherCandidates( + of listId: String, + search: String?, + limit: Int + ) async throws -> [CollaboratorCandidate] { try requireListManagement() - let dtos = try await api.send(Lists.watcherUsers(listId: listId)) - return dtos.map(ListWatcher.init(from:)) + // Blank searches are sent as `nil` so the route returns its default + // (unfiltered) candidate page rather than matching on an empty string. + let trimmed = search?.trimmingCharacters(in: .whitespacesAndNewlines) + let response = try await api.send( + Lists.watcherCandidates( + listId: listId, + search: (trimmed?.isEmpty ?? true) ? nil : trimmed, + limit: limit + ) + ) + return response.users.map { + CollaboratorCandidate( + id: $0.id, + username: $0.username, + displayName: $0.displayName, + email: $0.email, + avatar: $0.avatar.flatMap(URL.init(string:)) + ) + } } public func setWatcher( @@ -465,8 +580,17 @@ public final class ListsService: ListsServicing { ) async throws -> ListWatcher { try requireListManagement() let request = UpdateListWatcherRequest(role: role.wireToken) - let dto = try await api.send(Lists.setWatcher(listId: listId, userId: userId, request)) - return ListWatcher(from: dto) + do { + // The route answers `{ role }` only, so the caller's own `userId` + // completes the row. The server echoes the role it actually + // applied; we prefer that over the requested one. + let response = try await api.send(Lists.setWatcher(listId: listId, userId: userId, request)) + let applied = response.role.map(WatcherRole.init(wireToken:)) ?? role + return ListWatcher(userId: userId, role: applied) + } catch let error as APIError where error.httpStatusCode == 403 { + // Role changes are subscriber-gated exactly like add-watcher. + throw ListsError.subscriberRequired + } } public func removeWatcher(listId: String, userId: String) async throws { @@ -510,6 +634,23 @@ public final class ListsService: ListsServicing { /// Throws `ListsError.subscriberRequired` when the account is not /// entitled to manage lists. M3 ships this defensively; the actual /// `canManageLists` body becomes restrictive in M6. + /// + /// - TODO: GitHub issue **#40** owns `EntitlementsService` and is landing + /// the real capability model (`CapabilityGate`) on a separate branch. + /// `canManageLists` is still permissive-by-default here, so the + /// *client-side* gate does not yet distinguish the subscriber-only + /// sharing writes (`addWatcher` / `setWatcher`) from the free ones. + /// Until #40 merges, the server's `403` is the authoritative gate and + /// both call sites project it onto `ListsError.subscriberRequired`; + /// afterwards, point those two methods — and only those two — at the new + /// capability so the block happens before the round-trip. + /// + /// Deliberately *not* gated on `entitlements.isSubscriber` today, for + /// two reasons: `AppEnvironment` builds `ListsService` with the default + /// `.free` entitlements (so that check would reject every real caller), + /// and every one of this service's write methods routes through this one + /// seam — including pure reads — so tightening it wholesale would stop + /// free users reading their own lists. private func requireListManagement() throws { guard entitlements.canManageLists else { throw ListsError.subscriberRequired diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift index fc10052..7678386 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift @@ -35,6 +35,24 @@ public protocol SharingServicing: Sendable { func resolveListShare(token: String) async throws -> ResolvedShare func claimListShare(token: String) async throws -> ShareClaim + /// Reads one page of a token-shared list's rows + /// (`GET /api/lists/shared/{token}/data`, work-consolidation.md G23). + /// + /// The **token is the capability**: rows are served with no session and + /// regardless of the list's `isPublic` flag, which is what lets a + /// read-only share render a private list. Pairs with + /// `resolveListShare(token:)` — resolve gives the title and role, this + /// gives the rows. + func sharedListRows(token: String, limit: Int, offset: Int) async throws -> RowsPage + + /// Resolves an email invite for its landing page + /// (`GET /api/lists/invite/{token}`, work-consolidation.md G23). + /// + /// There is deliberately no `claimListInvite`: the accept half + /// (`POST /api/lists/invite/{token}`) is session-only in the live spec, so + /// a Bearer client cannot reach it. The landing hands accept to the browser. + func resolveListInvite(token: String) async throws -> ResolvedListInvite + // Documents func documentShareLinks(documentId: String) async throws -> [ShareLink] func createDocumentShareLink(documentId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink @@ -73,13 +91,16 @@ public final class SharingService: SharingServicing { private let api: APIClientProtocol private let entitlements: EntitlementsService + private let decoder: JSONDecoder public init( api: APIClientProtocol, - entitlements: EntitlementsService = EntitlementsService(customerStatus: .free) + entitlements: EntitlementsService = EntitlementsService(customerStatus: .free), + decoder: JSONDecoder = JSONCoders.makeDecoder() ) { self.api = api self.entitlements = entitlements + self.decoder = decoder } // MARK: Lists @@ -111,6 +132,27 @@ public final class SharingService: SharingServicing { return ShareClaim(resourceId: dto.listId, role: dto.role.flatMap(ShareRole.init(rawValue:))) } + public func sharedListRows(token: String, limit: Int, offset: Int) async throws -> RowsPage { + // Ungated on both axes: no entitlement check (reading a share you were + // given is always free) and no auth (`Lists.sharedRows` is `.none`). + let request = Lists.sharedRows(token: token, limit: limit, offset: offset) + let (data, _) = try await api.sendRaw(request) + let key = request.paginationKey ?? "data" + let paginated = try PaginatedDecoder.decode( + ListRowDTO.self, + collectionKey: key, + from: data, + decoder: decoder + ) + return RowsPage(from: paginated) + } + + public func resolveListInvite(token: String) async throws -> ResolvedListInvite { + // Accepting an invite is documented as always free, so no entitlement + // gate belongs on the landing read either. + ResolvedListInvite(from: try await api.send(Lists.invite(token: token))) + } + // MARK: Documents public func documentShareLinks(documentId: String) async throws -> [ShareLink] { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsSharingServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsSharingServiceTests.swift new file mode 100644 index 0000000..cfeab03 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsSharingServiceTests.swift @@ -0,0 +1,461 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for the work-consolidation.md G23 (issue #48) surface: the +/// "lists other people gave me access to" story. +/// +/// - `ListsService.watching` / `contributors` / `addWatcher` / +/// `watcherCandidates` +/// - `SharingService.sharedListRows` / `resolveListInvite` +/// +/// Quartet (happy / invalid / upstream-failure / boundary) is the floor for +/// each. Envelopes come from live read-only probes on 2026-09-09 except the +/// add-watcher write, whose shapes come from `/help/api/lists`. +final class ListsSharingServiceTests: XCTestCase { + + // MARK: - watching (shared with me) + + func test_givenListsSharedWithMe_whenLoadingWatching_thenMapsOwnerRoleAndParent() async throws { + // Given — two rows: one editable collaborator share nested under a + // parent, one read-only watcher share at the root. + let api = StubAPIClient() + await api.enqueue(json: """ + {"lists":[ + \(Fixtures.watchedListObject(id: "l-1", title: "Shows", role: "collaborator", parentTitle: "The Metal")), + \(Fixtures.watchedListObject(id: "l-2", title: "Videos", role: "watcher")) + ],"pagination":{"total":2,"limit":50,"offset":0,"hasMore":false}} + """) + let service = ListsService(api: api) + + // When + let page = try await service.watching(limit: 50, offset: 0) + + // Then + XCTAssertEqual(page.lists.map(\.id), ["l-1", "l-2"]) + XCTAssertEqual(page.lists.map(\.role), [.collaborator, .watcher]) + XCTAssertEqual(page.lists.first?.owner?.username, "adron") + XCTAssertEqual(page.lists.first?.owner?.displayLabel, "Adron Hall") + XCTAssertEqual(page.lists.first?.parentTitle, "The Metal") + XCTAssertEqual(page.lists.first?.title, "Shows") + XCTAssertFalse(page.hasMore) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/watching") + } + + func test_givenReadOnlyShare_whenLoadingWatching_thenCanEditIsFalse() async throws { + // The role decides whether the rows pane offers edit affordances. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope([(id: "l-1", role: "watcher")])) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertEqual(page.lists.first?.role, .watcher) + XCTAssertFalse(page.lists.first?.canEdit ?? true) + } + + func test_givenUnknownRoleToken_whenLoadingWatching_thenFallsBackToLeastPrivilege() async throws { + // Invalid input from upstream: a role the client does not know must + // never unlock editing. `.watcher` is the least-privileged role. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope([(id: "l-1", role: "overlord")])) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertEqual(page.lists.first?.role, .watcher) + XCTAssertFalse(page.lists.first?.canEdit ?? true) + } + + func test_givenMissingRole_whenLoadingWatching_thenFallsBackToLeastPrivilege() async throws { + // Same rule when the field is absent entirely. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope([(id: "l-1", role: nil)])) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertEqual(page.lists.first?.role, .watcher) + } + + func test_givenNothingShared_whenLoadingWatching_thenReturnsEmptyPage() async throws { + // Boundary: zero watched lists. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope([])) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertTrue(page.lists.isEmpty) + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextOffset) + } + + func test_givenMorePages_whenLoadingWatching_thenSurfacesNextOffset() async throws { + // Pagination: `hasMore` + the offset the next call should use. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope( + [(id: "l-1", role: "watcher")], + limit: 50, + offset: 50, + hasMore: true + )) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 50) + + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.nextOffset, 100) + } + + func test_givenWatchingAPIFailure_whenLoading_thenThrows() async throws { + // Upstream failure: no cache fallback here by design — the owned-list + // cache is a different slice and must not be polluted. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = ListsService(api: api) + + do { + _ = try await service.watching(limit: 50, offset: 0) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenWatchedListWithUnsharedParent_whenLoading_thenParentIsLabelOnly() async throws { + // Boundary from the issue: the `parent` projection must not imply + // access. It maps to a title string and nothing navigable. + let api = StubAPIClient() + await api.enqueue(json: """ + {"lists":[\(Fixtures.watchedListObject(id: "l-1", role: "collaborator", parentTitle: "Private Parent"))], + "pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}} + """) + let service = ListsService(api: api) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertEqual(page.lists.first?.parentTitle, "Private Parent") + // The parent id rides along on the underlying list, but nothing in + // `WatchedList` exposes it as an openable target. + XCTAssertEqual(page.lists.first?.list.parentID, "parent-l-1") + } + + // MARK: - contributors + + func test_givenRankedContributors_whenLoading_thenPreservesServerOrderAndCounts() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.contributorsEnvelope([ + (id: "c-1", added: 17, edited: 17), + (id: "c-2", added: 2, edited: 0) + ])) + let service = ListsService(api: api) + + let contributors = try await service.contributors(of: "l-1") + + XCTAssertEqual(contributors.map(\.id), ["c-1", "c-2"]) + XCTAssertEqual(contributors.first?.addedCount, 17) + XCTAssertEqual(contributors.first?.editedCount, 17) + XCTAssertEqual(contributors.first?.score, 34) + XCTAssertEqual(contributors.first?.displayLabel, "User c-1") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/l-1/contributors") + } + + func test_givenNoContributors_whenLoading_thenReturnsEmptyArray() async throws { + // Boundary — verified live against a brand-new list. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.contributorsEnvelope([])) + let service = ListsService(api: api) + + let contributors = try await service.contributors(of: "l-1") + + XCTAssertTrue(contributors.isEmpty) + } + + func test_givenContributorsOmitCounts_whenLoading_thenDefaultsToZero() async throws { + // Invalid/partial upstream row: counts absent. Zero beats a crash and + // beats a nil the view has to special-case. + let api = StubAPIClient() + await api.enqueue(json: #"{"contributors":[{"id":"c-1"}],"totalContributors":1}"#) + let service = ListsService(api: api) + + let contributors = try await service.contributors(of: "l-1") + + XCTAssertEqual(contributors.first?.addedCount, 0) + XCTAssertEqual(contributors.first?.editedCount, 0) + XCTAssertEqual(contributors.first?.score, 0) + XCTAssertEqual(contributors.first?.displayLabel, "Contributor") + } + + func test_givenContributorsAPIFailure_whenLoading_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no list")) + let service = ListsService(api: api) + + do { + _ = try await service.contributors(of: "l-1") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "no list")) + } + } + + // MARK: - addWatcher + + func test_givenUserAndRole_whenAddingWatcher_thenPostsToWatchers() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.addWatcherEnvelope) + let service = ListsService(api: api) + + try await service.addWatcher(listId: "l-1", userId: "u-9", role: .editor, notify: true) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/l-1/watchers") + } + + func test_givenBlankUserId_whenAddingWatcher_thenThrowsBeforeCallingAPI() async throws { + // Invalid input: an empty id would silently flip the route into its + // self-subscribe branch and add the *caller* instead. + let api = StubAPIClient() + let service = ListsService(api: api) + + do { + try await service.addWatcher(listId: "l-1", userId: " ", role: .editor, notify: true) + XCTFail("Expected ListsError.invalidWatcher") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidWatcher) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "An invalid watcher must not reach the network.") + } + + func test_givenFreeOwner_whenAddingWatcher_thenThrowsSubscriberRequired() async throws { + // Upstream failure: the documented 403 must arrive as the domain's + // subscriber case so the sharing UI shows an upsell. + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "Subscribe to share lists.")) + let service = ListsService(api: api) + + do { + try await service.addWatcher(listId: "l-1", userId: "u-9", role: .editor, notify: true) + XCTFail("Expected ListsError.subscriberRequired") + } catch let error as ListsError { + XCTAssertEqual(error, .subscriberRequired) + } + } + + func test_givenNonForbiddenFailure_whenAddingWatcher_thenSurfacesRawAPIError() async throws { + // Boundary on the 403 projection: only 403 becomes `subscriberRequired`. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no list")) + let service = ListsService(api: api) + + do { + try await service.addWatcher(listId: "l-1", userId: "u-9", role: .viewer, notify: false) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "no list")) + } + } + + func test_givenIdempotentReAdd_whenAddingWatcher_thenSucceedsQuietly() async throws { + // Boundary: a re-add answers 200 with the same body and no error — + // the route is documented as idempotent. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.addWatcherEnvelope) + let service = ListsService(api: api) + + try await service.addWatcher(listId: "l-1", userId: "u-9", role: .viewer, notify: false) + } + + // MARK: - watcher role wire tokens + + func test_givenWatcherRoles_whenWritten_thenUseTheDocumentedWireTokens() { + // Regression: the enum used to emit `owner`/`editor`/`viewer`, none of + // which the API accepts — every role write was a 400. + XCTAssertEqual(WatcherRole.viewer.wireToken, "watcher") + XCTAssertEqual(WatcherRole.editor.wireToken, "collaborator") + XCTAssertEqual(WatcherRole.owner.wireToken, "manager") + XCTAssertEqual(WatcherRole(wireToken: "manager"), .owner) + XCTAssertEqual(WatcherRole(wireToken: "collaborator"), .editor) + XCTAssertEqual(WatcherRole(wireToken: "watcher"), .viewer) + // Labels match the web's vocabulary and `ShareRole.label`. + XCTAssertEqual(WatcherRole.viewer.label, ShareRole.watcher.label) + XCTAssertEqual(WatcherRole.editor.label, ShareRole.collaborator.label) + XCTAssertEqual(WatcherRole.owner.label, ShareRole.manager.label) + } + + // MARK: - watcherCandidates + + func test_givenCandidateSearch_whenSearching_thenMapsUsersAndSendsQuery() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watcherCandidatesEnvelope(["u-9", "u-10"], total: 18)) + let service = ListsService(api: api) + + let candidates = try await service.watcherCandidates(of: "l-1", search: "ada", limit: 50) + + XCTAssertEqual(candidates.map(\.id), ["u-9", "u-10"]) + XCTAssertEqual(candidates.first?.email, "u-9@example.com") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/l-1/watchers/users") + XCTAssertEqual(recorded.first?.query["search"], "ada") + XCTAssertEqual(recorded.first?.query["limit"], "50") + } + + func test_givenBlankSearch_whenSearching_thenOmitsTheQueryParameter() async throws { + // Invalid/blank input: sending `search=` would filter on an empty + // string instead of returning the route's default page. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watcherCandidatesEnvelope([])) + let service = ListsService(api: api) + + _ = try await service.watcherCandidates(of: "l-1", search: " ", limit: 50) + + let recorded = await api.recorded + XCTAssertNil(recorded.first?.query["search"]) + } + + func test_givenNoCandidates_whenSearching_thenReturnsEmptyArray() async throws { + // Boundary: everyone matching already has access. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watcherCandidatesEnvelope([])) + let service = ListsService(api: api) + + let candidates = try await service.watcherCandidates(of: "l-1", search: nil, limit: 50) + + XCTAssertTrue(candidates.isEmpty) + } + + func test_givenCandidateSearchFailure_whenSearching_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "owner only")) + let service = ListsService(api: api) + + do { + _ = try await service.watcherCandidates(of: "l-1", search: nil, limit: 50) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "owner only")) + } + } + + // MARK: - sharedListRows (SharingService) + + func test_givenShareToken_whenReadingSharedRows_thenMapsRowsWithoutAuth() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows(ids: ["r-1", "r-2"])) + let service = SharingService(api: api) + + let page = try await service.sharedListRows(token: "tok", limit: 100, offset: 0) + + XCTAssertEqual(page.rows.map(\.id), ["r-1", "r-2"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/shared/tok/data") + XCTAssertEqual(recorded.first?.method, "GET") + } + + func test_givenSharedListWithNoRows_whenReading_thenReturnsEmptyPage() async throws { + // Boundary: an empty shared list still renders a landing. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows(ids: [])) + let service = SharingService(api: api) + + let page = try await service.sharedListRows(token: "tok", limit: 100, offset: 0) + + XCTAssertTrue(page.rows.isEmpty) + XCTAssertFalse(page.hasMore) + } + + func test_givenRevokedToken_whenReadingSharedRows_thenThrowsNotFound() async throws { + // Upstream failure, verified live: unknown / expired / revoked are one + // indistinguishable 404. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Share link not found, expired, or revoked")) + let service = SharingService(api: api) + + do { + _ = try await service.sharedListRows(token: "gone", limit: 100, offset: 0) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Share link not found, expired, or revoked")) + } + } + + func test_givenNonSubscriber_whenReadingSharedRows_thenIsNotGated() async throws { + // Reading a share you were given is free on every tier — an + // entitlement-blocking service must not intercept it. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows(ids: ["r-1"])) + let service = SharingService( + api: api, + entitlements: EntitlementsService(customerStatus: .free) + ) + + let page = try await service.sharedListRows(token: "tok", limit: 100, offset: 0) + + XCTAssertEqual(page.rows.map(\.id), ["r-1"]) + } + + // MARK: - resolveListInvite (SharingService) + + func test_givenClaimableInvite_whenResolving_thenMapsRoleTitleAndFlags() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.listInviteEnvelope()) + let service = SharingService(api: api) + + let invite = try await service.resolveListInvite(token: "tok") + + XCTAssertEqual(invite.role, .collaborator) + XCTAssertEqual(invite.resourceTitle, "Q3 Planning") + XCTAssertTrue(invite.canClaim) + XCTAssertFalse(invite.needsAuth) + XCTAssertFalse(invite.wrongAccount) + XCTAssertFalse(invite.accepted) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/invite/tok") + } + + func test_givenAnonymousResolve_whenFlagsOmitted_thenDefaultsAreConservative() async throws { + // Boundary: an anonymous resolve returns fewer fields. Missing flags + // must read as "not claimable / still open", never the reverse. + let api = StubAPIClient() + await api.enqueue(json: #"{"role":"watcher","needsAuth":true}"#) + let service = SharingService(api: api) + + let invite = try await service.resolveListInvite(token: "tok") + + XCTAssertEqual(invite.role, .watcher) + XCTAssertTrue(invite.needsAuth) + XCTAssertFalse(invite.canClaim) + XCTAssertFalse(invite.accepted) + XCTAssertNil(invite.resourceTitle) + } + + func test_givenUnknownInviteRole_whenResolving_thenFallsBackToLeastPrivilege() async throws { + // Invalid input from upstream: an unrecognised role must not imply + // more access than the least privileged one. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.listInviteEnvelope(role: "overlord")) + let service = SharingService(api: api) + + let invite = try await service.resolveListInvite(token: "tok") + + XCTAssertEqual(invite.role, .watcher) + } + + func test_givenExpiredInvite_whenResolving_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Invite not found, expired, or revoked")) + let service = SharingService(api: api) + + do { + _ = try await service.resolveListInvite(token: "gone") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Invite not found, expired, or revoked")) + } + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift index d83175e..9142d2d 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift @@ -865,10 +865,10 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenListHasWatchers_whenLoadingWatchers_thenMapsRoles() async throws { // Given let api = StubAPIClient() - await api.enqueue(json: Fixtures.watchersArray([ - (userId: "u-1", role: "owner"), - (userId: "u-2", role: "editor"), - (userId: "u-3", role: "viewer") + await api.enqueue(json: Fixtures.watchersEnvelope([ + (userId: "u-1", role: "manager"), + (userId: "u-2", role: "collaborator"), + (userId: "u-3", role: "watcher") ])) let service = ListsService(api: api) @@ -885,7 +885,7 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenListHasNoWatchers_whenLoadingWatchers_thenReturnsEmptyArray() async throws { // Given — boundary: a brand-new list nobody has shared with. let api = StubAPIClient() - await api.enqueue(json: "[]") + await api.enqueue(json: Fixtures.watchersEnvelope([])) let service = ListsService(api: api) // When @@ -913,7 +913,7 @@ final class OwnedListsServiceTests: XCTestCase { func test_givenWatcherWithUnknownRole_whenLoadingWatchers_thenPreservesRoleAsOther() async throws { // Given — boundary: an unknown role string preserves under `.other`. let api = StubAPIClient() - await api.enqueue(json: Fixtures.watchersArray([(userId: "u-1", role: "admin")])) + await api.enqueue(json: Fixtures.watchersEnvelope([(userId: "u-1", role: "admin")])) let service = ListsService(api: api) // When @@ -971,9 +971,9 @@ final class OwnedListsServiceTests: XCTestCase { // MARK: - set / remove watcher func test_givenUserAndRole_whenSettingWatcher_thenPutsAndMapsResponse() async throws { - // Given + // Given — the live route answers `{ role }`, not the watcher row. let api = StubAPIClient() - await api.enqueue(json: Fixtures.watcherObject(userId: "u-9", role: "editor")) + await api.enqueue(json: Fixtures.setWatcherRoleEnvelope(role: "collaborator")) let service = ListsService(api: api) // When @@ -991,10 +991,31 @@ final class OwnedListsServiceTests: XCTestCase { XCTAssertEqual(recorded.first?.path, "/api/lists/books/watchers/u-9") } + func test_givenSetWatcherRejectedAsNonSubscriber_whenSettingWatcher_thenThrowsSubscriberRequired() async throws { + // Given — role changes are subscriber-gated server-side, so a free + // owner's 403 must reach the UI as the upsell case, not a raw HTTP + // error (work-consolidation.md G23). + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "Subscribe to share lists.")) + let service = ListsService(api: api) + + // When / Then + do { + _ = try await service.setWatcher( + listId: "books", + userId: "u-9", + role: .editor + ) + XCTFail("Expected ListsError.subscriberRequired") + } catch let error as ListsError { + XCTAssertEqual(error, .subscriberRequired) + } + } + func test_givenSetWatcherAPIFailure_whenSettingWatcher_thenThrows() async throws { - // Given + // Given — a non-403 failure still surfaces as the raw `APIError`. let api = StubAPIClient() - await api.enqueue(failure: .forbidden(serverMessage: "only owners can share")) + await api.enqueue(failure: .notFound(serverMessage: "no such list")) let service = ListsService(api: api) // When / Then @@ -1006,7 +1027,7 @@ final class OwnedListsServiceTests: XCTestCase { ) XCTFail("Expected APIError") } catch let error as APIError { - XCTAssertEqual(error, .forbidden(serverMessage: "only owners can share")) + XCTAssertEqual(error, .notFound(serverMessage: "no such list")) } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift index 5b3ab8a..ac39a4e 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift @@ -154,7 +154,10 @@ final class SharingServiceTests: XCTestCase { func test_givenShareRoles_whenReadingLabelsAndCapabilities_thenMatchContract() { XCTAssertEqual(ShareRole.allCases.map(\.rawValue), ["watcher", "collaborator", "manager"]) - XCTAssertEqual(ShareRole.watcher.label, "Viewer") + // Labels follow the web's vocabulary on /help/lists + // (Read-only / Edit / Admin) — work-consolidation.md G23. + XCTAssertEqual(ShareRole.watcher.label, "Read-only") + XCTAssertEqual(ShareRole.collaborator.label, "Edit") XCTAssertEqual(ShareRole.manager.label, "Admin") XCTAssertFalse(ShareRole.watcher.canEdit) XCTAssertTrue(ShareRole.collaborator.canEdit) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift index 15c531f..2572d49 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift @@ -212,37 +212,211 @@ enum Fixtures { """ } - /// A single `ListWatcherDTO` object body. + /// A single `ListWatcherDTO` object body, in the shape the live route + /// returns: the person nested under `user` + /// (`/help/api/lists`: `{ id, userId, role, createdAt, user }`). static func watcherObject( userId: String, - role: String? = "editor", - username: String? = "ada" + role: String? = "collaborator", + username: String? = "ada", + displayName: String? = "Ada Lovelace" ) -> String { let roleJSON = role.map { "\"\($0)\"" } ?? "null" let usernameJSON = username.map { "\"\($0)\"" } ?? "null" + let displayNameJSON = displayName.map { "\"\($0)\"" } ?? "null" return """ { + "id": "w-\(userId)", "userId": "\(userId)", "role": \(roleJSON), - "username": \(usernameJSON), - "createdAt": "\(createdAtISO)" + "createdAt": "\(createdAtISO)", + "user": { + "id": "\(userId)", + "username": \(usernameJSON), + "displayName": \(displayNameJSON), + "avatar": null + } } """ } - /// A bare array of watcher objects (the shape `/watchers` returns). - static func watchersArray(_ entries: [(userId: String, role: String?)]) -> String { + /// The `{ "watchers": [...], "pagination": {...} }` envelope + /// `GET /api/lists/[id]/watchers` really returns (verified live + /// 2026-09-09 — work-consolidation.md G23). The route was previously + /// modelled as a bare array, which never decoded. + static func watchersEnvelope(_ entries: [(userId: String, role: String?)]) -> String { let objects = entries.map { watcherObject(userId: $0.userId, role: $0.role) } .joined(separator: ",") - return "[\(objects)]" + return """ + { + "watchers": [\(objects)], + "pagination": { + "total": \(entries.count), + "limit": 20, + "offset": 0, + "hasMore": false + } + } + """ } - /// `GET /api/lists/[id]/watchers/me` envelope. + /// `GET /api/lists/[id]/watchers/me` envelope. The wire key is `watching`, + /// not `isWatching` (verified live 2026-09-09). static func watcherStatusEnvelope(isWatching: Bool?, role: String?) -> String { let watchingJSON = isWatching.map { $0 ? "true" : "false" } ?? "null" let roleJSON = role.map { "\"\($0)\"" } ?? "null" return """ - { "isWatching": \(watchingJSON), "role": \(roleJSON) } + { "watching": \(watchingJSON), "role": \(roleJSON) } + """ + } + + /// `PUT /api/lists/[id]/watchers/[userId]` response: `{ "role" }`. + static func setWatcherRoleEnvelope(role: String?) -> String { + let roleJSON = role.map { "\"\($0)\"" } ?? "null" + return """ + { "role": \(roleJSON) } + """ + } + + // MARK: - G23 shared-with-me fixtures + + /// One `/api/lists/watching` row — the owned-list shape plus `role`, + /// the embedded owner, and the `parent` projection. + static func watchedListObject( + id: String, + title: String = "Shows Upcoming & Seen", + role: String? = "collaborator", + ownerUsername: String = "adron", + parentTitle: String? = nil, + isPublic: Bool = true + ) -> String { + let roleJSON = role.map { "\"\($0)\"" } ?? "null" + let parentJSON = parentTitle.map { + """ + { "id": "parent-\(id)", "title": "\($0)" } + """ + } ?? "null" + let parentIdJSON = parentTitle == nil ? "null" : "\"parent-\(id)\"" + return """ + { + "id": "\(id)", + "userId": "owner-\(id)", + "messageId": null, + "parentId": \(parentIdJSON), + "folderId": null, + "title": "\(title)", + "description": null, + "isPublic": \(isPublic), + "metadata": null, + "source": "local", + "githubRepo": null, + "githubRepoPrivate": null, + "createdAt": "\(createdAtISO)", + "updatedAt": "\(createdAtISO)", + "deletedAt": null, + "user": { + "id": "owner-\(id)", + "username": "\(ownerUsername)", + "displayName": "Adron Hall" + }, + "parent": \(parentJSON), + "children": [], + "role": \(roleJSON) + } + """ + } + + /// The `{ "lists": [...], "pagination": {...} }` envelope + /// `GET /api/lists/watching` returns. + static func watchingEnvelope( + _ entries: [(id: String, role: String?)], + limit: Int = 50, + offset: Int = 0, + hasMore: Bool = false + ) -> String { + let objects = entries + .map { watchedListObject(id: $0.id, role: $0.role) } + .joined(separator: ",") + return """ + { + "lists": [\(objects)], + "pagination": { + "total": \(entries.count), + "limit": \(limit), + "offset": \(offset), + "hasMore": \(hasMore) + } + } + """ + } + + /// `GET /api/lists/[id]/contributors` — `{ contributors, totalContributors }`. + static func contributorsEnvelope( + _ entries: [(id: String, added: Int, edited: Int)] + ) -> String { + let objects = entries.map { entry in + """ + { + "id": "\(entry.id)", + "username": "\(entry.id)", + "displayName": "User \(entry.id)", + "avatar": "https://cdn.interlinedlist.com/\(entry.id).png", + "addedCount": \(entry.added), + "editedCount": \(entry.edited), + "score": \(entry.added + entry.edited) + } + """ + }.joined(separator: ",") + return """ + { "contributors": [\(objects)], "totalContributors": \(entries.count) } + """ + } + + /// `GET /api/lists/[id]/watchers/users` — `{ users, total, pagination }`. + static func watcherCandidatesEnvelope(_ ids: [String], total: Int? = nil) -> String { + let objects = ids.map { id in + """ + { + "id": "\(id)", + "username": "\(id)", + "displayName": "User \(id)", + "email": "\(id)@example.com", + "avatar": null + } + """ + }.joined(separator: ",") + return """ + { + "users": [\(objects)], + "total": \(total ?? ids.count), + "pagination": { "limit": 50, "offset": 0, "hasMore": false } + } + """ + } + + /// `POST /api/lists/[id]/watchers` — the documented `{ watching: true }`. + static let addWatcherEnvelope: String = #"{ "watching": true }"# + + /// `GET /api/lists/invite/{token}` landing payload. + static func listInviteEnvelope( + role: String? = "collaborator", + needsAuth: Bool = false, + canClaim: Bool = true, + wrongAccount: Bool = false, + accepted: Bool = false, + resourceTitle: String? = "Q3 Planning" + ) -> String { + let roleJSON = role.map { "\"\($0)\"" } ?? "null" + let titleJSON = resourceTitle.map { "\"\($0)\"" } ?? "null" + return """ + { + "role": \(roleJSON), + "needsAuth": \(needsAuth), + "canClaim": \(canClaim), + "wrongAccount": \(wrongAccount), + "accepted": \(accepted), + "resourceTitle": \(titleJSON) + } """ } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift index 7adba8c..3e4c8b1 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift @@ -79,6 +79,44 @@ public struct ListDTO: Codable, Sendable, Equatable, Identifiable { public let createdAt: Date? public let updatedAt: Date? + // MARK: Shared-list projection (work-consolidation.md G23 / issue #48) + // + // `GET /api/lists/watching` answers rows that are a **superset** of the + // owned-list shape: the same list fields plus the caller's `role`, the + // owner under `user`, and a `parent` projection. Rather than fork a second + // list DTO, the extra keys land here as optionals — absent on every other + // list route, so `nil` there and every existing fixture decodes unchanged. + // + // VERIFIED live 2026-09-09 against the test account: + // {"lists":[{ …list…, "userId":…, "folderId":null, "source":"local", + // "githubRepo":null, "githubRepoPrivate":null, + // "user":{"id","username","displayName"}, + // "parent":{"id","title"}, "children":[], "role":"collaborator" }], + // "pagination":{ total, limit, offset, hasMore }} + + /// The owning user's id. Present on the authenticated list routes; on + /// `/api/lists/watching` it identifies the *other* user who shared the list. + public let userId: String? + /// The list-folder this list is filed under, or `nil` for the root. + public let folderId: String? + /// Row origin marker (`"local"`, `"github"`, …) when the route returns it. + public let source: String? + /// The `"owner/repo"` slug for a GitHub-backed list. + public let githubRepo: String? + /// Whether the backing GitHub repository is private. + public let githubRepoPrivate: Bool? + /// The **caller's** role on this list (`watcher` / `collaborator` / + /// `manager`). Only `/api/lists/watching` returns it. + public let role: String? + /// The list's owner. Only `/api/lists/watching` returns it. + public let user: ListUserDTO? + /// A lightweight projection of the parent list (id + title only). + /// + /// - Important: the presence of `parent` says nothing about whether the + /// caller can *open* the parent — a watched child whose parent was not + /// shared still carries this projection. Treat it as a label, not a link. + public let parent: ListParentDTO? + public init( id: String, title: String, @@ -87,7 +125,15 @@ public struct ListDTO: Codable, Sendable, Equatable, Identifiable { schema: String? = nil, parentId: String? = nil, createdAt: Date? = nil, - updatedAt: Date? = nil + updatedAt: Date? = nil, + userId: String? = nil, + folderId: String? = nil, + source: String? = nil, + githubRepo: String? = nil, + githubRepoPrivate: Bool? = nil, + role: String? = nil, + user: ListUserDTO? = nil, + parent: ListParentDTO? = nil ) { self.id = id self.title = title @@ -97,6 +143,50 @@ public struct ListDTO: Codable, Sendable, Equatable, Identifiable { self.parentId = parentId self.createdAt = createdAt self.updatedAt = updatedAt + self.userId = userId + self.folderId = folderId + self.source = source + self.githubRepo = githubRepo + self.githubRepoPrivate = githubRepoPrivate + self.role = role + self.user = user + self.parent = parent + } +} + +/// A user embedded in a list payload — the owner on `/api/lists/watching`, the +/// candidate rows on `/api/lists/[id]/watchers/users`, and the nested `user` +/// on a watcher row. Only `id` is guaranteed by every route. +public struct ListUserDTO: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let username: String? + public let displayName: String? + public let email: String? + public let avatar: String? + + public init( + id: String, + username: String? = nil, + displayName: String? = nil, + email: String? = nil, + avatar: String? = nil + ) { + self.id = id + self.username = username + self.displayName = displayName + self.email = email + self.avatar = avatar + } +} + +/// The `parent` projection on a watched list — id + title, nothing else. +public struct ListParentDTO: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + + public init(id: String, title: String) { + self.id = id + self.title = title } } @@ -161,40 +251,244 @@ public struct ListRowDTO: Codable, Sendable, Equatable, Identifiable { // MARK: - List watchers /// A watcher / access entry on a shared list. Role is a free string from the -/// API (`"watcher"`, `"collaborator"`, `"manager"`, …); the Domain layer maps -/// it to a typed role. Fields beyond `userId`/`role` are optional because the -/// API reference does not pin them down on every watcher route. +/// API (`"watcher"`, `"collaborator"`, `"manager"`); the Domain layer maps it +/// to a typed role. Fields beyond `userId`/`role` are optional because the API +/// does not pin them down on every watcher route. +/// +/// The route nests the person under `user` (`/help/api/lists`: `{ watchers: [ +/// { id, userId, role, createdAt, user } ] }`); the flat `username` is kept +/// for older fixtures and is read as a fallback by the domain mapper. public struct ListWatcherDTO: Codable, Sendable, Equatable { + /// The watcher-row id (distinct from `userId`). Not returned by every route. + public let id: String? public let userId: String public let role: String? public let username: String? + /// The nested person object the live route returns. + public let user: ListUserDTO? public let createdAt: Date? public init( + id: String? = nil, userId: String, role: String? = nil, username: String? = nil, + user: ListUserDTO? = nil, createdAt: Date? = nil ) { + self.id = id self.userId = userId self.role = role self.username = username + self.user = user self.createdAt = createdAt } } +/// `GET /api/lists/[id]/watchers` — `{ watchers: [...], pagination? }`. +/// +/// VERIFIED live 2026-09-09: the route answers this envelope, **not** a bare +/// `[ListWatcherDTO]` array. The builder previously declared the bare array, so +/// every watcher read failed at the decoder and the sharing panel could never +/// list anyone (issue #48 / G23 recon). +public struct ListWatchersResponse: Codable, Sendable, Equatable { + public let watchers: [ListWatcherDTO] + + public init(watchers: [ListWatcherDTO]) { + self.watchers = watchers + } +} + /// Response of `GET /api/lists/[id]/watchers/me` — the caller's own watcher -/// status on a list. Modelled tolerantly: `isWatching` plus the optional role. +/// status on a list. +/// +/// VERIFIED live 2026-09-09: the wire key is **`watching`**, not `isWatching`. +/// The Swift property keeps the `isWatching` name (it reads as a Bool) and +/// `CodingKeys` maps it; before this the flag decoded to `nil` on every call, +/// so the client believed the caller was never watching anything. public struct ListWatcherStatusDTO: Codable, Sendable, Equatable { public let isWatching: Bool? public let role: String? + private enum CodingKeys: String, CodingKey { + case isWatching = "watching" + case role + } + public init(isWatching: Bool? = nil, role: String? = nil) { self.isWatching = isWatching self.role = role } } +/// `GET /api/lists/[id]/watchers/users?search=…` — the **candidate** search +/// (people the owner could add), not the current watcher list. +/// +/// VERIFIED live 2026-09-09: `{ "users": [ { id, username, displayName, email, +/// avatar } ], "total": 18, "pagination": { limit, offset, hasMore } }`. The +/// builder previously declared `[ListWatcherDTO]`, which cannot decode this at +/// all — the rows carry no `userId` and no `role`. +public struct ListWatcherCandidatesResponse: Codable, Sendable, Equatable { + public let users: [ListUserDTO] + public let total: Int? + + public init(users: [ListUserDTO], total: Int? = nil) { + self.users = users + self.total = total + } +} + +/// `PUT /api/lists/[id]/watchers/[userId]` — `{ "role": "" }`. +/// +/// Documented on `/help/api/lists` and **not** the full watcher row the builder +/// previously decoded, so a role change failed at the decoder even when the +/// server applied it. +public struct SetListWatcherRoleResponse: Codable, Sendable, Equatable { + public let role: String? + + public init(role: String? = nil) { + self.role = role + } +} + +/// `POST /api/lists/[id]/watchers` — add a watcher (work-consolidation.md G23). +/// +/// Two documented modes share one body (`/help/api/lists`): +/// - **Owner grants a named user** — `userId` set; adds that user at `role` +/// (default `watcher`). Subscriber-gated: a free owner gets `403`. +/// - **Self-subscribe** — `userId` omitted; the caller watches a public list +/// that is not their own. Free, and `notify` is ignored. +/// +/// `notify` defaults to `true` server-side; pass `false` to grant access +/// without sending the recipient an email. +public struct AddListWatcherRequest: Codable, Sendable, Equatable { + public let userId: String? + public let role: String? + public let notify: Bool? + + public init(userId: String? = nil, role: String? = nil, notify: Bool? = nil) { + self.userId = userId + self.role = role + self.notify = notify + } +} + +/// `POST /api/lists/[id]/watchers` response. Documented as `{ watching: true }` +/// for the self-subscribe branch; the owner-grant branch was not re-probed live +/// (it is a write, and the recon account is shared), so every field is optional +/// and any of them signals success. `201` means a new grant, `200` an +/// idempotent re-add. +public struct AddListWatcherResponse: Codable, Sendable, Equatable { + public let watching: Bool? + public let role: String? + public let userId: String? + public let watcher: ListWatcherDTO? + + public init( + watching: Bool? = nil, + role: String? = nil, + userId: String? = nil, + watcher: ListWatcherDTO? = nil + ) { + self.watching = watching + self.role = role + self.userId = userId + self.watcher = watcher + } +} + +// MARK: - List contributors + +/// One ranked contributor to a list (`GET /api/lists/[id]/contributors`). +/// +/// VERIFIED live 2026-09-09: +/// `{"contributors":[{"id","username","displayName","avatar","addedCount", +/// "editedCount","score"}],"totalContributors":1}`. +public struct ListContributorDTO: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let username: String? + public let displayName: String? + public let avatar: String? + /// Rows this person added. + public let addedCount: Int? + /// Rows this person edited. + public let editedCount: Int? + /// The server's ranking score (`addedCount + editedCount` in the observed + /// payload, but treated as opaque — the client only sorts by it). + public let score: Int? + + public init( + id: String, + username: String? = nil, + displayName: String? = nil, + avatar: String? = nil, + addedCount: Int? = nil, + editedCount: Int? = nil, + score: Int? = nil + ) { + self.id = id + self.username = username + self.displayName = displayName + self.avatar = avatar + self.addedCount = addedCount + self.editedCount = editedCount + self.score = score + } +} + +/// `GET /api/lists/[id]/contributors` — `{ contributors: [...], totalContributors }`. +/// The route is documented as unpaged ("no server paging"). +public struct ListContributorsResponse: Codable, Sendable, Equatable { + public let contributors: [ListContributorDTO] + public let totalContributors: Int? + + public init(contributors: [ListContributorDTO], totalContributors: Int? = nil) { + self.contributors = contributors + self.totalContributors = totalContributors + } +} + +// MARK: - List email-invite landing + +/// `GET /api/lists/invite/{token}` — the email-invite landing payload. +/// +/// Shape from `/help/api/sharing`: +/// `{ role, needsAuth, canClaim, wrongAccount, accepted, resourceTitle }`. +/// The invited email address is deliberately never returned. +/// +/// - Note: the **accept** half (`POST /api/lists/invite/{token}`) is declared +/// `x-auth-type: session` in the live OpenAPI spec, so a Bearer-only client +/// cannot claim an invite. This DTO backs a landing view only. +public struct ResolvedListInviteDTO: Codable, Sendable, Equatable { + public let role: String? + /// `true` when nobody is signed in — prompt sign-in. + public let needsAuth: Bool? + /// `true` when the signed-in user's verified email matches the invite. + public let canClaim: Bool? + /// `true` when someone is signed in but under a different email. + public let wrongAccount: Bool? + /// `true` once the invite has already been claimed. + public let accepted: Bool? + /// The title of the list the invite grants access to. + public let resourceTitle: String? + + public init( + role: String? = nil, + needsAuth: Bool? = nil, + canClaim: Bool? = nil, + wrongAccount: Bool? = nil, + accepted: Bool? = nil, + resourceTitle: String? = nil + ) { + self.role = role + self.needsAuth = needsAuth + self.canClaim = canClaim + self.wrongAccount = wrongAccount + self.accepted = accepted + self.resourceTitle = resourceTitle + } +} + // MARK: - List connections /// A directed connection between two lists (powers the ERD / graph canvas). @@ -338,12 +632,18 @@ public struct UpdateListRowRequest: Codable, Sendable, Equatable { } } -/// `PUT /api/lists/[id]/watchers/[userId]` body: `{ "role": "" }`. +/// `PUT /api/lists/[id]/watchers/[userId]` body: `{ "role", "notify"? }`. +/// +/// `role` must be one of `watcher` / `collaborator` / `manager` — an invalid or +/// missing value is a `400`. `notify` defaults to `true` server-side and only +/// fires when the role actually changes (`/help/api/lists`). public struct UpdateListWatcherRequest: Codable, Sendable, Equatable { public let role: String + public let notify: Bool? - public init(role: String) { + public init(role: String, notify: Bool? = nil) { self.role = role + self.notify = notify } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift index 31eac9c..2fd2751 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift @@ -141,10 +141,95 @@ public enum Lists { Request(method: .delete, path: "/api/lists/\(listId)/data/\(rowId)", auth: .bearer) } + // MARK: - Shared with me (work-consolidation.md G23 / issue #48) + + /// `GET /api/lists/watching` — lists **other people** shared with the + /// caller. The web renders this as the datagrid on `/lists`; the macOS + /// sidebar had no equivalent before G23. + /// + /// VERIFIED live 2026-09-09 (`200`, three rows, `allow: GET, HEAD, + /// OPTIONS`): `{ "lists": [ …list superset… ], "pagination": {…} }`. Each + /// row carries `role` (the *caller's* role), an embedded `user` (the + /// owner), and a `parent` projection — see `ListDTO`. + public static func watching( + limit: Int? = nil, + offset: Int? = nil, + page: Int? = nil + ) -> Request> { + Request( + method: .get, + path: "/api/lists/watching", + query: [ + .int("limit", limit), + .int("offset", offset), + .int("page", page) + ], + auth: .bearer, + paginationKey: "lists" + ) + } + + /// `GET /api/lists/[id]/contributors` — the full ranked contributor list. + /// + /// VERIFIED live 2026-09-09 (`200`, `allow: GET, HEAD, OPTIONS`). Unpaged + /// by design ("no server paging" — live OpenAPI summary). + public static func contributors(listId: String) -> Request { + Request(method: .get, path: "/api/lists/\(listId)/contributors", auth: .bearer) + } + + // MARK: - Token-scoped share reads (no auth) + // + // The share-*link* builders live in `SharingEndpoint` (`Sharing.…`); these + // two sit here because they are list-shaped reads that the Lists UI + // consumes directly, and because issue #48 scopes them to this file. The + // matching write halves (`POST /api/lists/shared/{token}` and + // `POST /api/lists/invite/{token}`) are declared `x-auth-type: session` in + // the live spec and are therefore deliberately absent: a Bearer-only + // client cannot reach them, so there is no builder to call. + + /// `GET /api/lists/shared/{token}/data` — read-only row data for a + /// token-shared list. Public: the **token is the capability**, so rows are + /// served regardless of `isPublic` and with no session at all. + /// + /// VERIFIED live 2026-09-09: `allow: GET, HEAD, OPTIONS`, and an unknown + /// token answers `404 {"error":"Share link not found, expired, or + /// revoked","code":"not_found"}`. `/help/api/sharing` states it "returns + /// the same row payload shape" as `GET /api/lists/:id/data`, i.e. the + /// `{ rows, pagination }` envelope. + public static func sharedRows( + token: String, + limit: Int? = nil, + offset: Int? = nil + ) -> Request> { + Request( + method: .get, + path: "/api/lists/shared/\(token)/data", + query: [ + .int("limit", limit), + .int("offset", offset) + ], + auth: .none, + paginationKey: "rows" + ) + } + + /// `GET /api/lists/invite/{token}` — the email-invite landing payload. + /// + /// VERIFIED live 2026-09-09: reachable with no auth; an unknown token + /// answers `404 {"error":"Invite not found, expired, or revoked"}`. + /// Authentication is *optional* — signing in only changes `canClaim` / + /// `wrongAccount`; the invited address is never returned. + public static func invite(token: String) -> Request { + Request(method: .get, path: "/api/lists/invite/\(token)", auth: .none) + } + // MARK: - Watchers / sharing - /// `GET /api/lists/[id]/watchers` - public static func watchers(listId: String) -> Request<[ListWatcherDTO]> { + /// `GET /api/lists/[id]/watchers` — owner-only. + /// + /// VERIFIED live 2026-09-09: answers `{ watchers: [...], pagination }`, not + /// a bare array. See `ListWatchersResponse`. + public static func watchers(listId: String) -> Request { Request(method: .get, path: "/api/lists/\(listId)/watchers", auth: .bearer) } @@ -153,21 +238,59 @@ public enum Lists { Request(method: .get, path: "/api/lists/\(listId)/watchers/me", auth: .bearer) } - /// `GET /api/lists/[id]/watchers/users` - public static func watcherUsers(listId: String) -> Request<[ListWatcherDTO]> { - Request(method: .get, path: "/api/lists/\(listId)/watchers/users", auth: .bearer) + /// `GET /api/lists/[id]/watchers/users` — search **candidates** to add. + /// + /// Owner-only. `excludeWatchers` takes comma-separated ids; when it is + /// omitted the server auto-excludes the list's current watchers, which is + /// what the add-watcher picker wants, so the builder leaves it off. + /// + /// VERIFIED live 2026-09-09: answers `{ users, total, pagination }` — + /// people rows, not watcher rows. See `ListWatcherCandidatesResponse`. + public static func watcherCandidates( + listId: String, + search: String? = nil, + limit: Int? = nil, + offset: Int? = nil + ) -> Request { + Request( + method: .get, + path: "/api/lists/\(listId)/watchers/users", + query: [ + .string("search", search), + .int("limit", limit), + .int("offset", offset) + ], + auth: .bearer + ) + } + + /// `POST /api/lists/[id]/watchers` — add a watcher (work-consolidation.md + /// G23). Owner-granting a named user is **subscriber-gated** server-side + /// (`403 {"error":"Subscribe to share lists."}` for a free owner); the + /// self-subscribe branch (no `userId`) is free. + /// + /// VERIFIED live 2026-09-09 by `OPTIONS`: `allow: GET, HEAD, OPTIONS, POST`. + /// The body/response shapes come from `/help/api/lists` — the write itself + /// was not exercised (the recon account is shared; G23 recon was read-only). + public static func addWatcher( + listId: String, + _ body: AddListWatcherRequest + ) -> Request { + Request(method: .post, path: "/api/lists/\(listId)/watchers", body: .json(body), auth: .bearer) } - /// `PUT /api/lists/[id]/watchers/[userId]` + /// `PUT /api/lists/[id]/watchers/[userId]` — change a role. Owner-only and + /// subscriber-gated. Answers `{ role }`, not the watcher row. public static func setWatcher( listId: String, userId: String, _ body: UpdateListWatcherRequest - ) -> Request { + ) -> Request { Request(method: .put, path: "/api/lists/\(listId)/watchers/\(userId)", body: .json(body), auth: .bearer) } - /// `DELETE /api/lists/[id]/watchers/[userId]` + /// `DELETE /api/lists/[id]/watchers/[userId]` — not subscriber-gated, so a + /// downgraded owner can always revoke access. public static func removeWatcher(listId: String, userId: String) -> Request { Request(method: .delete, path: "/api/lists/\(listId)/watchers/\(userId)", auth: .bearer) } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift index 8931377..36af400 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsEndpointTests.swift @@ -53,7 +53,9 @@ final class ListsEndpointTests: XCTestCase { XCTAssertEqual(Lists.watchers(listId: "7").path, "/api/lists/7/watchers") XCTAssertEqual(Lists.myWatcherStatus(listId: "7").path, "/api/lists/7/watchers/me") - XCTAssertEqual(Lists.watcherUsers(listId: "7").path, "/api/lists/7/watchers/users") + XCTAssertEqual(Lists.watcherCandidates(listId: "7").path, "/api/lists/7/watchers/users") + XCTAssertEqual(Lists.addWatcher(listId: "7", AddListWatcherRequest(userId: "u2")).method, .post) + XCTAssertEqual(Lists.addWatcher(listId: "7", AddListWatcherRequest(userId: "u2")).path, "/api/lists/7/watchers") XCTAssertEqual(Lists.setWatcher(listId: "7", userId: "u2", UpdateListWatcherRequest(role: "manager")).method, .put) XCTAssertEqual(Lists.setWatcher(listId: "7", userId: "u2", UpdateListWatcherRequest(role: "manager")).path, "/api/lists/7/watchers/u2") XCTAssertEqual(Lists.removeWatcher(listId: "7", userId: "u2").method, .delete) diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsSharingEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsSharingEndpointTests.swift new file mode 100644 index 0000000..7c6d582 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListsSharingEndpointTests.swift @@ -0,0 +1,387 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the five Lists routes work-consolidation.md G23 (issue #48) +/// adds — the "lists other people gave me access to" story — plus the decode +/// corrections G23 recon turned up on the existing watcher routes. +/// +/// Every shape asserted here was verified against the live API on 2026-09-09 +/// with read-only probes (`GET` / `OPTIONS` only), or comes from +/// `/help/api/lists` and `/help/api/sharing` where the route is a write the +/// recon pass deliberately did not exercise. +final class ListsSharingEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + // The same stub backs the session transport so the decision-0001 401 + // safety net (which retries once over the session) reads from one + // queue — otherwise a 401 test surfaces a transport error from the + // empty second stub instead of the 401 under test. + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: transport, + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + // MARK: - Builder shape + + func test_givenG23Builders_whenConstructed_thenUseExpectedMethodPathAuth() { + // watching — Bearer, paginated under "lists". + XCTAssertEqual(Lists.watching().method, .get) + XCTAssertEqual(Lists.watching().path, "/api/lists/watching") + XCTAssertEqual(Lists.watching().auth, .bearer) + XCTAssertEqual(Lists.watching().paginationKey, "lists") + + // contributors — Bearer, unpaged. + XCTAssertEqual(Lists.contributors(listId: "7").method, .get) + XCTAssertEqual(Lists.contributors(listId: "7").path, "/api/lists/7/contributors") + XCTAssertEqual(Lists.contributors(listId: "7").auth, .bearer) + XCTAssertNil(Lists.contributors(listId: "7").paginationKey) + + // addWatcher — Bearer POST with a body. + let add = Lists.addWatcher(listId: "7", AddListWatcherRequest(userId: "u2", role: "collaborator", notify: false)) + XCTAssertEqual(add.method, .post) + XCTAssertEqual(add.path, "/api/lists/7/watchers") + XCTAssertEqual(add.auth, .bearer) + XCTAssertNotNil(add.body) + + // sharedRows — the token IS the capability, so no auth at all. + XCTAssertEqual(Lists.sharedRows(token: "tok").method, .get) + XCTAssertEqual(Lists.sharedRows(token: "tok").path, "/api/lists/shared/tok/data") + XCTAssertEqual(Lists.sharedRows(token: "tok").auth, .none) + XCTAssertEqual(Lists.sharedRows(token: "tok").paginationKey, "rows") + + // invite landing — public. + XCTAssertEqual(Lists.invite(token: "tok").method, .get) + XCTAssertEqual(Lists.invite(token: "tok").path, "/api/lists/invite/tok") + XCTAssertEqual(Lists.invite(token: "tok").auth, .none) + } + + func test_givenOptionalQuery_whenG23BuildersBuilt_thenSkipsNilParameters() { + // Boundary: nil query parameters are dropped, not sent empty. + let watching = Lists.watching(limit: 10, offset: nil, page: nil) + XCTAssertEqual(Set(watching.query.compactMap { $0.value != nil ? $0.name : nil }), ["limit"]) + + let rows = Lists.sharedRows(token: "tok", limit: nil, offset: 40) + XCTAssertEqual(Set(rows.query.compactMap { $0.value != nil ? $0.name : nil }), ["offset"]) + + // `excludeWatchers` is deliberately never sent: omitting it makes the + // server auto-exclude the list's current watchers. + let candidates = Lists.watcherCandidates(listId: "7", search: nil, limit: nil, offset: nil) + XCTAssertTrue(candidates.query.allSatisfy { $0.value == nil }) + } + + // MARK: - watching round-trip + + func test_givenSharedLists_whenFetchingWatching_thenDecodesRoleOwnerAndParent() async throws { + // Given — the exact live envelope (2026-09-09), trimmed to two rows. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"lists":[ + {"id":"l-1","userId":"c65","messageId":null,"parentId":"p-1","folderId":null, + "title":"Shows Upcoming & Seen","description":null,"isPublic":true,"metadata":null, + "source":"local","githubRepo":null,"githubRepoPrivate":null, + "createdAt":"2026-03-21T22:48:20.897Z","updatedAt":"2026-07-11T04:54:54.332Z","deletedAt":null, + "user":{"id":"c65","username":"adron","displayName":"Adron Hall"}, + "parent":{"id":"p-1","title":"The Metal"},"children":[],"role":"collaborator"}, + {"id":"l-2","userId":"c65","parentId":null,"folderId":null, + "title":"Videos to Watch","description":null,"isPublic":true, + "createdAt":"2026-03-21T22:48:20.897Z","updatedAt":"2026-07-11T04:54:54.332Z", + "user":{"id":"c65","username":"adron","displayName":"Adron Hall"}, + "parent":null,"children":[],"role":"watcher"} + ],"pagination":{"total":2,"limit":50,"offset":0,"hasMore":false}} + """, status: 200)) + + // When + let (data, _) = try await client.sendRaw(Lists.watching()) + let page = try PaginatedDecoder.decode( + ListDTO.self, + collectionKey: "lists", + from: data, + decoder: JSONCoders.makeDecoder() + ) + + // Then + XCTAssertEqual(page.items.map(\.id), ["l-1", "l-2"]) + XCTAssertEqual(page.items.map(\.role), ["collaborator", "watcher"]) + XCTAssertEqual(page.items.first?.user?.username, "adron") + XCTAssertEqual(page.items.first?.parent?.title, "The Metal") + XCTAssertEqual(page.items.first?.userId, "c65") + XCTAssertNil(page.items.last?.parent) + XCTAssertFalse(page.pagination.hasMore) + } + + func test_givenNothingShared_whenFetchingWatching_thenDecodesEmptyPage() async throws { + // Boundary: an account nobody has shared with. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"lists":[],"pagination":{"total":0,"limit":50,"offset":0,"hasMore":false}} + """, status: 200)) + + let (data, _) = try await client.sendRaw(Lists.watching()) + let page = try PaginatedDecoder.decode( + ListDTO.self, + collectionKey: "lists", + from: data, + decoder: JSONCoders.makeDecoder() + ) + + XCTAssertTrue(page.items.isEmpty) + XCTAssertFalse(page.pagination.hasMore) + } + + func test_givenUnauthorizedCaller_whenFetchingWatching_thenThrowsUnauthorized() async throws { + // Upstream-failure: the spec declares 401 as the only failure mode. + let (client, transport) = makeClient() + // Two: the safety net retries a Bearer 401 once over the session + // transport before surfacing the status. + await transport.enqueue(.json(#"{"error":"Unauthorized"}"#, status: 401)) + await transport.enqueue(.json(#"{"error":"Unauthorized"}"#, status: 401)) + + do { + _ = try await client.sendRaw(Lists.watching()) + XCTFail("Expected APIError.unauthorized") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 401) + } + } + + // MARK: - contributors round-trip + + func test_givenRankedContributors_whenFetching_thenDecodesCountsAndScore() async throws { + // Given — the exact live payload (2026-09-09). + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"contributors":[{"id":"c65","username":"adron","displayName":"Adron Hall", + "avatar":"https://cdn.example/a.jpg","addedCount":17,"editedCount":17,"score":34}], + "totalContributors":1} + """, status: 200)) + + let response = try await client.send(Lists.contributors(listId: "l-1")) + + XCTAssertEqual(response.totalContributors, 1) + XCTAssertEqual(response.contributors.first?.username, "adron") + XCTAssertEqual(response.contributors.first?.addedCount, 17) + XCTAssertEqual(response.contributors.first?.score, 34) + } + + func test_givenNoContributors_whenFetching_thenDecodesEmptyList() async throws { + // Boundary: verified live against a brand-new list. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"contributors":[],"totalContributors":0}"#, status: 200)) + + let response = try await client.send(Lists.contributors(listId: "l-1")) + + XCTAssertTrue(response.contributors.isEmpty) + XCTAssertEqual(response.totalContributors, 0) + } + + // MARK: - addWatcher + + func test_givenNamedUser_whenAddingWatcher_thenSendsUserIdRoleAndNotify() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"watching":true}"#, status: 201)) + + let response = try await client.send( + Lists.addWatcher(listId: "l-1", AddListWatcherRequest(userId: "u-9", role: "collaborator", notify: false)) + ) + + XCTAssertEqual(response.watching, true) + let sent = await transport.received + XCTAssertEqual(sent.last?.httpMethod, "POST") + XCTAssertEqual(sent.last?.url?.path, "/api/lists/l-1/watchers") + let body = try XCTUnwrap(sent.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["userId"] as? String, "u-9") + XCTAssertEqual(json["role"] as? String, "collaborator") + XCTAssertEqual(json["notify"] as? Bool, false) + } + + func test_givenFreeOwner_whenAddingWatcher_thenThrowsForbidden() async throws { + // Upstream-failure: the documented subscriber gate. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Subscribe to share lists."}"#, status: 403)) + + do { + _ = try await client.send( + Lists.addWatcher(listId: "l-1", AddListWatcherRequest(userId: "u-9")) + ) + XCTFail("Expected APIError.forbidden") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 403) + } + } + + func test_givenSelfSubscribeBody_whenEncoded_thenOmitsUserId() throws { + // Boundary: the documented second mode of the same route — no + // `userId` means "watch this list myself", which is free. + let data = try JSONCoders.makeEncoder().encode(AddListWatcherRequest()) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNil(json["userId"]) + XCTAssertNil(json["role"]) + } + + // MARK: - shared row data + + func test_givenValidShareToken_whenFetchingSharedRows_thenDecodesRowsEnvelope() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"rows":[{"id":"r-1","listId":"l-1","rowData":{"bands":"Igorrr","date":"2026-09-28"}, + "createdAt":"2026-07-12T03:58:54.991Z","updatedAt":"2026-07-12T03:58:54.991Z"}], + "pagination":{"total":17,"limit":1,"offset":0,"hasMore":true}} + """, status: 200)) + + let (data, _) = try await client.sendRaw(Lists.sharedRows(token: "tok", limit: 1)) + let page = try PaginatedDecoder.decode( + ListRowDTO.self, + collectionKey: "rows", + from: data, + decoder: JSONCoders.makeDecoder() + ) + + XCTAssertEqual(page.items.map(\.id), ["r-1"]) + XCTAssertEqual(page.items.first?.rowData["bands"]?.stringValue, "Igorrr") + XCTAssertTrue(page.pagination.hasMore) + } + + func test_givenSharedRowsRequest_whenBuilt_thenCarriesNoAuthorizationHeader() async throws { + // The token is the capability; sending a Bearer would be pointless and + // would leak the caller's identity to a public route. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"rows":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#, status: 200)) + + _ = try await client.sendRaw(Lists.sharedRows(token: "tok")) + + let sent = await transport.received + XCTAssertNil(sent.last?.value(forHTTPHeaderField: "Authorization")) + } + + func test_givenRevokedShareToken_whenFetchingSharedRows_thenThrowsNotFound() async throws { + // Upstream-failure, verified live: unknown / expired / revoked are one + // deliberately indistinguishable 404. + let (client, transport) = makeClient() + await transport.enqueue( + .json(#"{"error":"Share link not found, expired, or revoked","code":"not_found"}"#, status: 404) + ) + + do { + _ = try await client.sendRaw(Lists.sharedRows(token: "gone")) + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 404) + } + } + + // MARK: - invite landing + + func test_givenClaimableInvite_whenResolving_thenDecodesEveryFlag() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"role":"collaborator","needsAuth":false,"canClaim":true, + "wrongAccount":false,"accepted":false,"resourceTitle":"Q3 Planning"} + """, status: 200)) + + let dto = try await client.send(Lists.invite(token: "tok")) + + XCTAssertEqual(dto.role, "collaborator") + XCTAssertEqual(dto.canClaim, true) + XCTAssertEqual(dto.needsAuth, false) + XCTAssertEqual(dto.wrongAccount, false) + XCTAssertEqual(dto.accepted, false) + XCTAssertEqual(dto.resourceTitle, "Q3 Planning") + } + + func test_givenAnonymousCaller_whenResolvingInvite_thenTolerateOmittedFlags() async throws { + // Boundary: an anonymous resolve returns less. Every field is optional + // so the landing still renders instead of failing at the decoder. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"role":"watcher","needsAuth":true}"#, status: 200)) + + let dto = try await client.send(Lists.invite(token: "tok")) + + XCTAssertEqual(dto.role, "watcher") + XCTAssertEqual(dto.needsAuth, true) + XCTAssertNil(dto.canClaim) + XCTAssertNil(dto.resourceTitle) + } + + func test_givenExpiredInvite_whenResolving_thenThrowsNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue( + .json(#"{"error":"Invite not found, expired, or revoked","code":"not_found"}"#, status: 404) + ) + + do { + _ = try await client.send(Lists.invite(token: "gone")) + XCTFail("Expected APIError.notFound") + } catch let error as APIError { + XCTAssertEqual(error.httpStatusCode, 404) + } + } + + // MARK: - Watcher-route decode corrections (G23 recon) + + func test_givenWatchersEnvelope_whenDecoded_thenReadsNestedUser() async throws { + // Regression: the route answers `{ watchers: [...] }`, not a bare + // array, and nests the person under `user`. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"watchers":[{"id":"w-1","userId":"u-1","role":"collaborator", + "createdAt":"2026-06-16T12:00:00Z", + "user":{"id":"u-1","username":"ada","displayName":"Ada Lovelace","avatar":null}}], + "pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}} + """, status: 200)) + + let response = try await client.send(Lists.watchers(listId: "l-1")) + + XCTAssertEqual(response.watchers.map(\.userId), ["u-1"]) + XCTAssertEqual(response.watchers.first?.user?.username, "ada") + XCTAssertEqual(response.watchers.first?.id, "w-1") + } + + func test_givenWatcherStatus_whenDecoded_thenReadsWatchingKey() async throws { + // Regression: the wire key is `watching`, not `isWatching`. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"watching":true,"role":"collaborator"}"#, status: 200)) + + let dto = try await client.send(Lists.myWatcherStatus(listId: "l-1")) + + XCTAssertEqual(dto.isWatching, true) + XCTAssertEqual(dto.role, "collaborator") + } + + func test_givenCandidateSearch_whenDecoded_thenReadsUsersEnvelope() async throws { + // Regression: `/watchers/users` returns people, not watcher rows. + let (client, transport) = makeClient() + await transport.enqueue(.json(""" + {"users":[{"id":"u-9","username":"Ajdixa","displayName":"Someone", + "email":"a@example.com","avatar":null}], + "total":18,"pagination":{"limit":1,"offset":0,"hasMore":true}} + """, status: 200)) + + let response = try await client.send(Lists.watcherCandidates(listId: "l-1", search: "aj", limit: 1)) + + XCTAssertEqual(response.users.map(\.id), ["u-9"]) + XCTAssertEqual(response.users.first?.email, "a@example.com") + XCTAssertEqual(response.total, 18) + } + + func test_givenRoleChangeResponse_whenDecoded_thenReadsRoleOnly() async throws { + // Regression: the PUT answers `{ role }`, not the watcher row. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"role":"manager"}"#, status: 200)) + + let response = try await client.send( + Lists.setWatcher(listId: "l-1", userId: "u-9", UpdateListWatcherRequest(role: "manager", notify: false)) + ) + + XCTAssertEqual(response.role, "manager") + } +} diff --git a/work-consolidation.md b/work-consolidation.md index 77db669..1222313 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -152,8 +152,20 @@ Live and available to the test account: `GET /api/ai/status` returns `{"subscrib Three routes the DM feature shipped without: `GET /api/dm/conversations` (one row per conversation grouped by `pairKey`, newest first — verified live, `{"items":[],"nextCursor":null}` — this is the natural inbox list, versus today's folder-based `GET /api/dm`), `GET /api/dm/{id}` (single message), and `POST /api/dm/images/upload` (DM image attachments, which the DM composer advertises but cannot perform). -**G23 · Lists: shared-with-me, contributors, watcher add — MEDIUM. Size M.** -`GET /api/lists/watching` (verified live, returns real rows) is the **"shared with me" / watched-lists** surface the sidebar lacks. Also `GET /api/lists/{id}/contributors` (full ranked contributor list), `POST /api/lists/{id}/watchers` (add watchers — the client can only read and delete), `GET /api/lists/shared/{token}/data` (row data for a token-shared list, the read-only viewer's missing half), and the invite landing pair `GET`/`POST /api/lists/invite/{token}`. +**G23 · Lists: shared-with-me, contributors, watcher add — ✅ SHIPPED 2026-09-09 (branch `feat/lists-sharing-g23`, GitHub issue #48).** +All five routes are built: `GET /api/lists/watching` (the **"shared with me"** sidebar section the client had no equivalent of), `GET /api/lists/{id}/contributors` (ranked contributor panel), `POST /api/lists/{id}/watchers` (add a watcher — the client could previously only read and delete), `GET /api/lists/shared/{token}/data` (the read-only viewer's missing half: a share landing now renders the rows), and `GET /api/lists/invite/{token}` (the email-invite landing). + +> **The accept half stays in the browser.** `POST /api/lists/invite/{token}` and `POST /api/lists/shared/{token}` are declared `x-auth-type: session` in the live OpenAPI spec, so a Bearer-only client cannot claim either. The invite landing therefore ends in "Accept in Browser" rather than a native Accept button that would 401 every time. That backend ask is filed separately; `InviteLandingViewModel.acceptInBrowserURL` is the single seam a Bearer-reachable claim route would replace. + +> **The read-only probe that preceded this work found four shipping defects in the *existing* watcher surface, all fixed here.** None had been caught because each fails silently at the decoder or as a 400 the UI reports as a generic error: +> 1. `GET /api/lists/{id}/watchers` answers `{"watchers":[…],"pagination":{…}}`, but the builder declared a bare `[ListWatcherDTO]` — so the sharing panel could never list anyone. +> 2. `GET /api/lists/{id}/watchers/me` answers `{"watching":…}`, not `{"isWatching":…}` — the flag decoded to `nil` on every call, so the client believed the caller watched nothing. +> 3. `GET /api/lists/{id}/watchers/users` is a **candidate search** ("people you could add", `{"users":[…],"total","pagination"}`), not the watcher list. It was typed as `[ListWatcherDTO]`, which cannot decode it at all — and the watchers panel was calling it to populate itself. +> 4. `WatcherRole.wireToken` emitted `owner`/`editor`/`viewer`, none of which the API accepts. `/help/api/lists` documents the taxonomy as `watcher`/`collaborator`/`manager`, and an invalid role is a `400` — so every role change was rejected. UI labels now follow the web's vocabulary (Read-only / Edit / Admin) on both `WatcherRole` and `ShareRole`. + +> **Still open, found during the same probe and deliberately out of scope:** `GET /api/lists/{id}` answers the `{"data":{…}}` envelope while `Lists.get(id:)` decodes a bare `ListDTO`, so `ListsService.detail(listId:)` cannot decode a live response. Not exercised by any G23 path (the shared-with-me rows come from the `watching` payload itself), so it is filed rather than fixed here. + +> **Entitlements:** granting a named user access is subscriber-gated server-side (`403 {"error":"Subscribe to share lists."}`), as is a role change. `ListsService` projects that 403 onto `ListsError.subscriberRequired` so the UI shows the same upsell Share Links and Invite by Email already use. The client-side pre-flight gate is left to GitHub #40 (`CapabilityGate`) — every `ListsService` write routes through one permissive `canManageLists` seam today, so tightening it here would have blocked free users from reading their own lists. **G24 · Documents: sidebar tree, public docs, invites, presence — MEDIUM. Size M.**