diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index 4780be6..b3aa2a2 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -487,7 +487,12 @@ final class AppEnvironment: ObservableObject { // deltas stay consistent (stale-while-revalidate paint). store: documentStore, // Live image ceilings for `uploadImage` prep (G14 tail). - contentLimits: contentLimits + contentLimits: contentLimits, + // work-consolidation.md G24 — `POST /api/documents/folders/{id}/ + // documents` is subscriber-gated upstream. Same live box the + // messages gate reads, so a mid-session subscribe or lapse re-gates + // creating documents in a folder without a relaunch. + entitlementsProvider: { liveEntitlements.current() } ) // Server document templates (work-consolidation.md G12). Reuses the same // kit-layer `APIClient` like the other services do — the diff --git a/App/Features/Documents/DocumentInviteDeepLink.swift b/App/Features/Documents/DocumentInviteDeepLink.swift new file mode 100644 index 0000000..29dae9e --- /dev/null +++ b/App/Features/Documents/DocumentInviteDeepLink.swift @@ -0,0 +1,103 @@ +// DocumentInviteDeepLink +// +// Turns an opened document-invite URL into a routed presentation of +// `DocumentInviteView` (work-consolidation.md G24). The address is the `url` +// the server puts in the invite email: +// `https://interlinedlist.com/documents/invite/{token}`. +// +// Deliberately separate from `ShareURLParser` / `ShareLinkDeepLink` in the +// Sharing feature rather than an extra case on them. A share link and an email +// invite are different objects with different routes, different response +// shapes and — critically — different capabilities: a share link can be +// claimed from this app, an invite cannot (see `DocumentInviteViewModel`). +// Folding them into one parser would put a claim button one boolean away from +// a surface that must never show one. +// +// Mirrors the project's deep-link convention: a `Notification.Name` colocated +// with the feature, a static poster so the URL handler in `InterlinedListApp` +// stays a one-liner, and `MainWindowView` owning the sheet presentation. +// +// Decision 0003: App-layer only; this file needs nothing but Foundation. + +import Foundation + +/// An invite reference extracted from a URL — the opaque token to resolve. +struct ParsedDocumentInvite: Equatable { + let token: String +} + +enum DocumentInviteURLParser { + + /// The `interlinedlist://` custom scheme the app registers, shared with + /// the OAuth callback and the share-link handler. + static let scheme = "interlinedlist" + + /// Parses a URL into a `ParsedDocumentInvite`, or `nil` when it is not a + /// document invite link. Accepts the `https` web address the invite email + /// carries and the custom scheme, and tolerates the resource word landing + /// in the host (`interlinedlist://documents/invite/{token}` parses + /// "documents" as the host, not a path segment). + static func parse(_ url: URL) -> ParsedDocumentInvite? { + var segments: [String] = [] + if let host = url.host, + !host.isEmpty, + host != "interlinedlist.com", + host != "www.interlinedlist.com" { + segments.append(host) + } + segments.append(contentsOf: url.pathComponents.filter { $0 != "/" && !$0.isEmpty }) + return match(segments) + } + + /// Parses a raw string (e.g. pasted from an invite email). Trims first so + /// a pasted line with a trailing newline still parses. + static func parse(string: String) -> ParsedDocumentInvite? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return parse(url) + } + + // MARK: - Matching + + /// Matches `[…, "documents", "invite", ]` at the tail. + /// + /// `documents` is required, not optional: `/lists/invite/{token}` is a + /// *list* invite with its own route, and quietly treating it as a document + /// invite would resolve the wrong resource. + private static func match(_ segments: [String]) -> ParsedDocumentInvite? { + let cleaned = segments.filter { $0.lowercased() != "share" } + guard cleaned.count >= 3 else { return nil } + let tail = Array(cleaned.suffix(3)) + guard tail[0].lowercased() == "documents", + tail[1].lowercased() == "invite", + !tail[2].isEmpty else { return nil } + return ParsedDocumentInvite(token: tail[2]) + } +} + +extension Foundation.Notification.Name { + /// Posted when an opened URL resolves to a document invite. `object` is the + /// `ParsedDocumentInvite`. Observed by `MainWindowView`, which presents + /// `DocumentInviteView`. + static let openDocumentInvite = Foundation.Notification.Name("InterlinedList.openDocumentInvite") +} + +enum DocumentInviteDeepLink { + + /// Attempts to route `url` as a document invite. Returns `true` (and posts + /// `.openDocumentInvite`) on a hit; `false` otherwise so the caller falls + /// through to the share-link and OAuth handlers. `post` defaults to `nil`, + /// in which case the parsed invite goes to `NotificationCenter.default`; + /// tests pass a capturing closure to observe routing without the center. + @discardableResult + @MainActor + static func handle(_ url: URL, post: ((ParsedDocumentInvite) -> Void)? = nil) -> Bool { + guard let parsed = DocumentInviteURLParser.parse(url) else { return false } + if let post { + post(parsed) + } else { + NotificationCenter.default.post(name: .openDocumentInvite, object: parsed) + } + return true + } +} diff --git a/App/Features/Documents/DocumentInviteView.swift b/App/Features/Documents/DocumentInviteView.swift new file mode 100644 index 0000000..d9e2678 --- /dev/null +++ b/App/Features/Documents/DocumentInviteView.swift @@ -0,0 +1,214 @@ +// DocumentInviteView +// +// The document email-invite landing (work-consolidation.md G24). Presented as +// a sheet when a `…/documents/invite/{token}` link is opened — pasted, or +// delivered via the `interlinedlist://` deep-link scheme. +// +// It shows what the invite grants and ends in "Accept in your browser", and +// that is the whole surface by design: `POST /api/documents/invite/{token}` +// is session-cookie-only in the live spec, so this app cannot claim an invite. +// Rendering an accept button here would be an affordance that always fails. +// Instead the landing states the branch the person is in and hands off a link. +// Accepting is documented as always free, so there is no entitlement gate. +// +// Mirrors `ResolveShareView`'s shape (header / branch / footer, sheet-sized) +// so the two landings feel like one family — but it is a separate view for a +// separate route, not a case bolted onto that one. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct DocumentInviteView: View { + + let parsed: ParsedDocumentInvite + let environment: AppEnvironment + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var viewModel: DocumentInviteViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Opening invite") + .padding() + } + } + .frame(minWidth: 420, minHeight: 280) + .task { + if viewModel == nil { + let model = DocumentInviteViewModel( + documents: environment.documentsService, + token: parsed.token, + webBaseURL: environment.shareBaseURL + ) + viewModel = model + await model.resolve() + } + } + } + + @ViewBuilder + private func content(viewModel: DocumentInviteViewModel) -> 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 let error = viewModel.error, viewModel.invite == nil { + errorState(error: error, viewModel: viewModel) + } else if let invite = viewModel.invite { + resolvedState(invite: invite, viewModel: viewModel) + } + + Spacer() + footer(viewModel: viewModel) + } + .padding(16) + } + + // MARK: - States + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Document Invitation") + .font(.ilTitle()) + Text("Someone invited you to a document on InterlinedList.") + .font(.ilBody()) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func resolvedState( + invite: DocumentInvite, + viewModel: DocumentInviteViewModel + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + LabeledContent("Document") { + Text(viewModel.displayTitle) + .font(.ilBody()) + } + LabeledContent("Access") { + Text(roleDescription(invite.role)) + .font(.ilBody()) + } + + Divider() + + // One line per branch. The copy names the *browser* explicitly in + // every case, because that is where the invite is completed. + switch viewModel.nextStep { + case .alreadyAccepted: + stepMessage( + icon: "checkmark.circle", + text: "You've already accepted this invitation. Open the document in your browser." + ) + case .signInInBrowser: + stepMessage( + icon: "person.crop.circle.badge.questionmark", + text: "Sign in on the web to accept. Invitations are accepted in the browser, not in this app." + ) + case .wrongAccount: + stepMessage( + icon: "person.crop.circle.badge.exclamationmark", + text: "The account signed in on the web doesn't match the invited address. Switch accounts in your browser, then accept." + ) + case .acceptInBrowser, .none: + stepMessage( + icon: "safari", + text: "Accepting an invitation happens in your browser. Accepting is free — no subscription required." + ) + } + } + } + + private func stepMessage(icon: String, text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text(text) + .font(.ilBody()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private func errorState( + error: Error, + viewModel: DocumentInviteViewModel + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text("This invitation can't be opened") + .font(.ilSubtitle()) + } + // The server answers one 404 for unknown / expired / revoked / + // deleted so tokens can't be probed; say all four rather than + // guessing which one it was. + Text("The link may have expired, been revoked, or the document may no longer exist.") + .font(.ilBody()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Button("Try again") { + Task { await viewModel.resolve() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func footer(viewModel: DocumentInviteViewModel) -> some View { + HStack { + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + if let url = viewModel.acceptURL { + Button { + openURL(url) + } label: { + Label(acceptButtonTitle(viewModel.nextStep), systemImage: "arrow.up.forward.app") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .help("Opens interlinedlist.com — invitations are accepted in the browser") + } + } + } + + // MARK: - Copy + + private func acceptButtonTitle(_ step: DocumentInviteViewModel.NextStep?) -> String { + switch step { + case .alreadyAccepted: return "Open in Browser" + case .signInInBrowser: return "Sign In in Browser" + case .wrongAccount: return "Open in Browser" + case .acceptInBrowser, .none: return "Accept in Browser" + } + } + + /// Turns the wire role into the phrasing the web uses for the same grant. + private func roleDescription(_ role: String) -> String { + switch role.lowercased() { + case "watcher": return "View only" + case "collaborator": return "Can edit" + case "manager": return "Can manage" + default: return role + } + } +} diff --git a/App/Features/Documents/DocumentInviteViewModel.swift b/App/Features/Documents/DocumentInviteViewModel.swift new file mode 100644 index 0000000..5787b9b --- /dev/null +++ b/App/Features/Documents/DocumentInviteViewModel.swift @@ -0,0 +1,127 @@ +// DocumentInviteViewModel +// +// Drives `DocumentInviteView` — the landing shown when a document email +// invite is opened, whether pasted or delivered via the `interlinedlist://` +// deep-link scheme (work-consolidation.md G24). +// +// It resolves the token through `GET /api/documents/invite/{token}` and +// surfaces the document title, the granted role, and which branch of the +// landing applies (sign in / wrong account / ready to accept / already +// accepted). +// +// **There is no accept intent, and there will not be one until the backend +// changes.** `POST /api/documents/invite/{token}` is `x-auth-type: session` +// in the live spec — authenticated by the browser session cookie only — so a +// Bearer sync-token client cannot claim an invite however the request is +// shaped. The view model's job ends at "here is what this invite is"; the +// accept step is handed to the browser via `acceptURL`. That is a backend +// constraint filed separately, not a gap in this layer, so this deliberately +// does *not* mirror `ResolveShareViewModel.claimAccess()`. +// +// Reads through `DocumentsServicing` only. Per decision 0003, this view model +// consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class DocumentInviteViewModel { + + // MARK: - Dependencies + + private let documents: DocumentsServicing + + /// The opaque token this landing resolves. + let token: String + + /// Base web address used to build the browser hand-off. Injected so tests + /// don't need the environment. + private let webBaseURL: URL + + // MARK: - Observable state + + /// The resolved invite once `resolve()` succeeds. `nil` before the first + /// resolve or after a failure. + private(set) var invite: DocumentInvite? + + /// True while the resolve round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed resolve. + private(set) var error: Error? + + // MARK: - Init + + init(documents: DocumentsServicing, token: String, webBaseURL: URL) { + self.documents = documents + self.token = token + self.webBaseURL = webBaseURL + } + + // MARK: - Derived UI state + + /// The document title to show, falling back to a neutral phrase when the + /// server withheld it. + var displayTitle: String { + invite?.resourceTitle ?? "this document" + } + + /// The address that completes the invite in a browser. Always available + /// once resolved — accepting is browser-only, so this is the primary + /// action rather than a fallback. + var acceptURL: URL? { + invite?.acceptURL(base: webBaseURL) + } + + /// What the landing should tell the person to do next. Ordered by + /// precedence: an already-claimed invite is terminal, then the two + /// account problems, then the ready case. + enum NextStep: Equatable { + /// Already claimed — nothing to do but open the document. + case alreadyAccepted + /// Nobody is signed in on the web; they must sign in there first. + case signInInBrowser + /// Signed in on the web under a different address than the invite. + case wrongAccount + /// Everything lines up — finish in the browser. + case acceptInBrowser + } + + /// The branch to render, or `nil` before the invite resolves. + var nextStep: NextStep? { + guard let invite else { return nil } + if invite.accepted { return .alreadyAccepted } + if invite.needsAuth { return .signInInBrowser } + if invite.wrongAccount { return .wrongAccount } + return .acceptInBrowser + } + + // MARK: - Intents + + /// Resolves the token, populating `invite`. Refuses a blank token before + /// the service call. Re-entrant-safe: a second call while one is in flight + /// is dropped. + func resolve() async { + guard !isLoading else { return } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + invite = nil + error = DocumentsUIError.invalidInviteToken + return + } + isLoading = true + defer { isLoading = false } + do { + invite = try await documents.invite(token: trimmed) + error = nil + } catch { + // The server collapses unknown / expired / revoked / deleted into + // one 404 so tokens can't be probed. Keep that collapse — do not + // guess which of the four it was. + invite = nil + self.error = error + } + } +} diff --git a/App/Features/Documents/DocumentsListView.swift b/App/Features/Documents/DocumentsListView.swift index 8fdd9c5..e0935c4 100644 --- a/App/Features/Documents/DocumentsListView.swift +++ b/App/Features/Documents/DocumentsListView.swift @@ -2,7 +2,8 @@ // // Middle column of the M4 Documents three-column split // (PLAN.md §6 M4). Lists documents in the currently-selected folder -// (or the unfiled root) with a per-row context menu for delete. +// (or the unfiled root) with a per-row context menu for move + delete +// (work-consolidation.md G24 adds the move half). // Pure SwiftUI; no AppKit involvement. import SwiftUI @@ -13,6 +14,15 @@ struct DocumentsListView: View { let viewModel: DocumentsListViewModel let onSelect: (Document.ID?) -> Void + /// Source of the **Move to folder** destinations. Optional so the column + /// still renders in isolation (previews, and any future host that has no + /// sidebar); the move item is simply absent without it. + var folderTree: FolderTreeViewModel? = nil + + /// Called with the document that was moved, so the host can rebind an open + /// editor to the server's relocated copy. + var onMoved: ((Document) -> Void)? = nil + var body: some View { List(selection: Binding( get: { viewModel.selectedDocumentID }, @@ -51,6 +61,27 @@ struct DocumentsListView: View { DocumentRowView(document: doc) .tag(doc.id) .contextMenu { + if let folderTree { + MoveToFolderMenu( + folderTree: folderTree, + currentFolderID: doc.folderId + ) { destination in + Task { + if let moved = await viewModel.moveDocument( + id: doc.id, + to: destination + ) { + onMoved?(moved) + // The sidebar's per-folder counts + // came from the tree call, so both + // the source and destination badges + // are now stale. + await folderTree.refresh() + } + } + } + Divider() + } Button(role: .destructive) { Task { await viewModel.deleteDocument(id: doc.id) } } label: { diff --git a/App/Features/Documents/DocumentsListViewModel.swift b/App/Features/Documents/DocumentsListViewModel.swift index bdc0671..21efc87 100644 --- a/App/Features/Documents/DocumentsListViewModel.swift +++ b/App/Features/Documents/DocumentsListViewModel.swift @@ -156,12 +156,31 @@ final class DocumentsListViewModel { return nil } do { - let doc = try await documents.create( - title: trimmedTitle, - body: body, - folderId: folderID, - isPublic: isPublic - ) + // work-consolidation.md G24 — route by destination, not by flag. + // + // `POST /api/documents` is documented as "always creates at root: + // there is no `folderId` in its body", so the `folderId:` argument + // this used to pass was silently dropped: a New Document created + // with a folder selected landed at root and vanished from the + // column the user was looking at. Creating inside a folder has its + // own route, and this is the only place that decides between them. + let doc: Document + if let folderID { + doc = try await documents.createDocument( + inFolder: folderID, + title: trimmedTitle, + body: body, + isPublic: isPublic, + relativePath: nil + ) + } else { + doc = try await documents.create( + title: trimmedTitle, + body: body, + folderId: nil, + isPublic: isPublic + ) + } documentsLoaded.insert(doc, at: 0) selectedDocumentID = doc.id error = nil @@ -172,6 +191,50 @@ final class DocumentsListViewModel { } } + /// Moves a document into `folderID`, or out to root when `folderID` is + /// `nil`. Optimistic: the row leaves the rendered column immediately when + /// the destination differs from the folder being viewed, and is restored + /// with the error surfaced if the service refuses. + /// + /// Returns the relocated document on success so the caller can rebind an + /// open editor to the server's copy. + @discardableResult + func moveDocument(id: Document.ID, to destinationFolderID: FolderNode.ID?) async -> Document? { + guard let index = documentsLoaded.firstIndex(where: { $0.id == id }) else { + // Nothing rendered to move. Not an error the user caused — the row + // may have been removed by a sync delta a moment earlier. + return nil + } + // No-op guard: moving a document to the folder it is already in would + // spend a round-trip to change nothing. + guard documentsLoaded[index].folderId != destinationFolderID else { return nil } + + let previous = documentsLoaded + let previousSelection = selectedDocumentID + // The column shows exactly one folder, so a document moved anywhere + // else no longer belongs in it. + let leavesThisColumn = destinationFolderID != folderID + if leavesThisColumn { + documentsLoaded.remove(at: index) + if selectedDocumentID == id { selectedDocumentID = nil } + } + do { + let moved = try await documents.moveDocument(id: id, toFolder: destinationFolderID) + if !leavesThisColumn, let idx = documentsLoaded.firstIndex(where: { $0.id == id }) { + // Moved *into* the folder being viewed — keep the row and take + // the server's copy so `folderId` is authoritative. + documentsLoaded[idx] = moved + } + error = nil + return moved + } catch { + documentsLoaded = previous + selectedDocumentID = previousSelection + self.error = error + return nil + } + } + /// Creates a new document seeded from a client-side `DocumentTemplate` /// (work-consolidation.md). The chosen template supplies the starter /// Markdown body; the title defaults to the template name but the caller diff --git a/App/Features/Documents/DocumentsRootView.swift b/App/Features/Documents/DocumentsRootView.swift index d99f36e..b52561b 100644 --- a/App/Features/Documents/DocumentsRootView.swift +++ b/App/Features/Documents/DocumentsRootView.swift @@ -150,10 +150,24 @@ struct DocumentsRootView: View { } .frame(minWidth: 200, idealWidth: 240) - DocumentsListView(viewModel: documentsList) { docID in - let document = documentsList.documentsLoaded.first { $0.id == docID } - editor.bind(to: document) - } + DocumentsListView( + viewModel: documentsList, + onSelect: { docID in + let document = documentsList.documentsLoaded.first { $0.id == docID } + editor.bind(to: document) + }, + // work-consolidation.md G24 — the row context menu's + // "Move to folder" reads its destinations from the same + // tree the sidebar paints from. + folderTree: folderTree, + onMoved: { moved in + // Keep an open editor pointed at the server's copy so + // its folder is right after the move. + if editor.document?.id == moved.id { + editor.bind(to: moved) + } + } + ) .frame(minWidth: 220, idealWidth: 280) Group { @@ -241,6 +255,26 @@ struct DocumentsRootView: View { .disabled(editor.document == nil) .help("Control whether anyone with the link can view this document") + // work-consolidation.md G24 — the document-settings half of + // "Move to folder". Same menu the list's context menu uses, so + // the destinations and the `_templates` exclusion match. + if let openDocument = editor.document { + MoveToFolderMenu( + folderTree: folderTree, + currentFolderID: openDocument.folderId + ) { destination in + Task { + await handleMove( + documentID: openDocument.id, + to: destination, + folderTree: folderTree, + documentsList: documentsList, + editor: editor + ) + } + } + } + Button { editor.exportMarkdown() } label: { @@ -390,6 +424,30 @@ struct DocumentsRootView: View { } } + /// Moves the open document and reconciles the three panes: the list drops + /// or keeps the row, the editor rebinds to the server's copy, and the + /// sidebar's per-folder counts are refetched (they came from the tree + /// call, so both the source and destination badges went stale). + /// + /// The move itself is driven through `DocumentsListViewModel` even when it + /// was started from the editor, so there is exactly one optimistic-rollback + /// implementation rather than two that can disagree. + private func handleMove( + documentID: Document.ID, + to destination: FolderNode.ID?, + folderTree: FolderTreeViewModel, + documentsList: DocumentsListViewModel, + editor: DocumentEditorViewModel + ) async { + guard let moved = await documentsList.moveDocument(id: documentID, to: destination) else { + return + } + if editor.document?.id == moved.id { + editor.bind(to: moved) + } + await folderTree.refresh() + } + private func handleOpenLocalCopy( _ id: Document.ID, documentsList: DocumentsListViewModel, diff --git a/App/Features/Documents/DocumentsSidebarView.swift b/App/Features/Documents/DocumentsSidebarView.swift index 9643ad6..3dbd2ec 100644 --- a/App/Features/Documents/DocumentsSidebarView.swift +++ b/App/Features/Documents/DocumentsSidebarView.swift @@ -5,6 +5,14 @@ // context menu for rename / delete / new sub-folder, and a // "Documents" root row that means "show unfiled documents." // Pure SwiftUI; no AppKit involvement. +// +// work-consolidation.md G24: the whole thing is painted from one call. +// `FolderTreeViewModel` now reads `GET /api/documents/tree`, which carries +// each folder's documents inline — so the per-folder counts rendered here +// cost nothing extra. A count is shown only once the tree has actually +// landed (`documentCount(for:)` returns `nil` while the view is painting +// from cache), so a cached row never displays an authoritative-looking +// zero it hasn't earned. import SwiftUI import InterlinedDomain @@ -30,8 +38,12 @@ struct DocumentsSidebarView: View { } )) { // Unfiled root — selecting it shows documents with no folder. - Label("All Documents", systemImage: "tray") - .tag(FolderNode.ID?.none) + HStack { + Label("All Documents", systemImage: "tray") + Spacer() + FolderCountBadge(count: viewModel.documentCount(for: nil)) + } + .tag(FolderNode.ID?.none) if viewModel.folders.isEmpty, viewModel.isLoading { ProgressView() @@ -61,6 +73,7 @@ struct DocumentsSidebarView: View { FolderSidebarRow( folder: folder, tree: tree, + documentCount: { viewModel.documentCount(for: $0) }, onRenameRequested: { id, current in pendingRenameID = id renameDraft = current @@ -172,6 +185,10 @@ private struct FolderSidebarRow: View { let folder: FolderNode let tree: FolderTree + /// How many documents are filed directly in a folder, or `nil` when the + /// tree hasn't landed yet. Passed as a closure so the recursive row does + /// not need the whole view model. + let documentCount: (FolderNode.ID) -> Int? let onRenameRequested: (FolderNode.ID, String) -> Void let onDeleteRequested: (FolderNode.ID) -> Void let onAddSubfolderRequested: (FolderNode.ID) -> Void @@ -187,6 +204,7 @@ private struct FolderSidebarRow: View { FolderSidebarRow( folder: child, tree: tree, + documentCount: documentCount, onRenameRequested: onRenameRequested, onDeleteRequested: onDeleteRequested, onAddSubfolderRequested: onAddSubfolderRequested @@ -201,8 +219,14 @@ private struct FolderSidebarRow: View { } private var row: some View { - Label(folder.name, systemImage: "folder") - .contextMenu { + HStack { + Label(folder.name, systemImage: "folder") + Spacer() + // Direct children only — the tree nests documents under the folder + // that holds them, so a parent does not roll up its sub-folders. + FolderCountBadge(count: documentCount(folder.id)) + } + .contextMenu { Button { onAddSubfolderRequested(folder.id) } label: { @@ -219,6 +243,28 @@ private struct FolderSidebarRow: View { } label: { Label("Delete", systemImage: "trash") } - } + } + } +} + +// MARK: - Folder count badge + +/// Trailing document count for a sidebar row. +/// +/// Renders nothing at all for `nil` (the tree hasn't landed) *and* for zero — +/// an empty folder reads better as a folder with no badge than as one +/// annotated "0". The distinction still matters upstream: `nil` means unknown, +/// so nothing is claimed about a folder we haven't fetched. +private struct FolderCountBadge: View { + + let count: Int? + + var body: some View { + if let count, count > 0 { + Text("\(count)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(count) documents") + } } } diff --git a/App/Features/Documents/FolderTreeViewModel.swift b/App/Features/Documents/FolderTreeViewModel.swift index c0d0def..bc3eaf2 100644 --- a/App/Features/Documents/FolderTreeViewModel.swift +++ b/App/Features/Documents/FolderTreeViewModel.swift @@ -6,6 +6,19 @@ // cache access — so unit tests substitute a stub trivially // (PLAN.md §3, §7). // +// work-consolidation.md G24: the sidebar is now built from **one** call. +// `documents.documentTree()` (`GET /api/documents/tree`) returns every folder +// with its documents inline plus the unfiled root documents, so the folder +// fetch and the per-folder document counts arrive together where the folder +// list used to be one call and counts were simply unavailable. +// +// Two things the tree does *not* retire, verified against the live shape +// before the swap: its inline document rows carry no body and no `updatedAt`, +// so `DocumentsListViewModel` keeps its own `documents(in:)` fetch for the +// middle column and the editor keeps its own read. The tree's summaries are +// used for counts, for the "move to folder" destination list, and to identify +// the machine-managed `_templates` folder — never as documents to open. +// // "Root" (no selected folder) is a first-class state: when // `selectedFolderID == nil`, the documents list shows top-level // (unfiled) documents. @@ -20,19 +33,29 @@ import InterlinedDomain @Observable final class FolderTreeViewModel { - /// Page size for the folders fetch. Folders are not paged in the - /// UI today — the request asks for a large page so the entire tree - /// arrives in one round-trip. + /// Retained page size for the legacy `documents.folders(limit:offset:)` + /// read. The sidebar no longer uses it — `documentTree()` is unpaged and + /// returns the whole account — but the constant stays public-to-the-target + /// because other call sites and tests reference it. static let pageSize: Int = 500 private let documents: DocumentsServicing // MARK: - Observable state - /// Folders loaded so far, in input order. Used to derive - /// `tree` lazily on each render. + /// Folders rendered in the sidebar, in server order. Excludes the + /// machine-managed `_templates` folder, which the tree returns inline + /// with the user's own folders but which is not a browsable destination. + /// Used to derive `tree` lazily on each render. private(set) var folders: [FolderNode] = [] + /// The most recent successful `documentTree()` result. Backs the per-folder + /// document counts and the move-destination list. Empty until the first + /// network revalidation lands — the cache paint fills `folders` but has no + /// document membership to offer, so counts stay hidden until then rather + /// than flashing a wrong zero. + private(set) var snapshot: DocumentTreeSnapshot = DocumentTreeSnapshot() + /// Currently selected folder id, if any. `nil` means "root" — the /// documents list shows unfiled documents. var selectedFolderID: FolderNode.ID? @@ -86,7 +109,10 @@ final class FolderTreeViewModel { /// revalidate over the network in the background. Cold start (empty /// cache) keeps the blocking-spinner behavior. Safe to call repeatedly. func initialLoad() async { - let cached = await documents.cachedFolders() + // The cache holds every folder the sync engine has seen, `_templates` + // included. Filter it the same way the tree paint does so the sidebar + // doesn't briefly show a folder that then disappears on revalidation. + let cached = Self.browsable(await documents.cachedFolders()) let paintedFromCache = !cached.isEmpty if paintedFromCache { folders = cached @@ -140,7 +166,9 @@ final class FolderTreeViewModel { return } guard let index = folders.firstIndex(where: { $0.id == id }) else { return } - let snapshot = folders + // Named `previous`, not `snapshot` — `snapshot` is now the tree + // property on this view model. + let previous = folders // Optimistic update — swap the in-memory copy first. let original = folders[index] folders[index] = FolderNode( @@ -158,7 +186,7 @@ final class FolderTreeViewModel { } error = nil } catch { - folders = snapshot + folders = previous self.error = error } } @@ -167,14 +195,14 @@ final class FolderTreeViewModel { /// first, then calls the service; on failure restores the snapshot /// and surfaces the error. func deleteFolder(id: String) async { - let snapshot = folders + let previous = folders folders.removeAll { $0.id == id } if selectedFolderID == id { selectedFolderID = nil } do { try await documents.deleteFolder(id: id) error = nil } catch { - folders = snapshot + folders = previous self.error = error } } @@ -183,11 +211,64 @@ final class FolderTreeViewModel { /// documents event loop to refresh after a `deltaApplied` event /// without a refetch. func replace(folders: [FolderNode]) { - self.folders = folders + self.folders = Self.browsable(folders) + } + + // MARK: - Derived sidebar data (G24) + + /// How many documents are filed directly in `folderID` — or at root when + /// `nil` — according to the last successful tree fetch. + /// + /// `nil` (rather than `0`) while the snapshot is cold, so a sidebar row + /// painted from cache shows no badge instead of an authoritative-looking + /// zero. Direct children only: a parent folder does not count documents in + /// its sub-folders, because the tree does not nest them that way. + func documentCount(for folderID: FolderNode.ID?) -> Int? { + guard hasTreeSnapshot else { return nil } + return snapshot.documentCount(in: folderID) + } + + /// True once a `documentTree()` call has landed. Distinguishes "no + /// documents" from "we have not asked yet". + var hasTreeSnapshot: Bool { + lastRefreshedAt != nil + } + + /// The folders offered as a **Move to folder** destination, in sidebar + /// order. Never includes `_templates`: it is created and populated by the + /// server for the template picker, and filing a normal document into it + /// would make that document show up as a template. + /// + /// "No folder (root)" is not in this list — the caller renders it as a + /// separate `nil` destination. + var moveDestinations: [FolderNode] { + // Prefer the rendered folder list so an optimistically-created folder + // is immediately offered, rather than waiting for the next tree fetch. + folders + } + + /// The id of the machine-managed `_templates` folder, when the account has + /// one. `nil` before the template picker has ever been opened. + var templatesFolderID: FolderNode.ID? { + snapshot.templatesFolderID + } + + /// The folder holding `documentID` per the last tree fetch, and whether the + /// snapshot knows the document at all. Used by the move action to grey out + /// the destination the document is already in. + func currentFolderID(ofDocument documentID: String) -> FolderNode.ID? { + snapshot.folderID(ofDocument: documentID) } // MARK: - Internals + /// Drops the machine-managed `_templates` folder from a folder list. + /// The tree, the cache and the sync deltas all carry it; none of the three + /// should put it in the sidebar. + private static func browsable(_ folders: [FolderNode]) -> [FolderNode] { + folders.filter { $0.name != DocumentTreeSnapshot.templatesFolderName } + } + /// Runs the network load and folds the result in. When `cachePainted` /// is true the spinner is suppressed (`isRefreshing`) and a failure /// keeps the cached folders on screen (`refreshFailed`) rather than @@ -204,8 +285,12 @@ final class FolderTreeViewModel { isRefreshing = false } do { - let page = try await documents.folders(limit: Self.pageSize, offset: 0) - folders = page + // G24: one call. `documentTree()` carries the folders *and* their + // document membership, replacing the folders-only fetch that used + // to sit here. + let tree = try await documents.documentTree() + snapshot = tree + folders = tree.userFolders error = nil lastRefreshedAt = Date() } catch { @@ -226,6 +311,12 @@ enum DocumentsUIError: Error, Equatable, LocalizedError { case invalidFolderName case invalidDocumentTitle case imageTooLargeAfterPrep + /// A blank handle was submitted to the public-documents column + /// (work-consolidation.md G24). Caught in the view model so the empty + /// path never reaches the network. + case invalidUsername + /// A blank invite token was submitted to the invite landing. + case invalidInviteToken var errorDescription: String? { switch self { @@ -235,6 +326,10 @@ enum DocumentsUIError: Error, Equatable, LocalizedError { return "Document title cannot be empty." case .imageTooLargeAfterPrep: return "Image is too large to upload, even after compression." + case .invalidUsername: + return "Enter a username to see their public documents." + case .invalidInviteToken: + return "That doesn't look like an invite link." } } } diff --git a/App/Features/Documents/MoveToFolderMenu.swift b/App/Features/Documents/MoveToFolderMenu.swift new file mode 100644 index 0000000..1ff34ff --- /dev/null +++ b/App/Features/Documents/MoveToFolderMenu.swift @@ -0,0 +1,64 @@ +// MoveToFolderMenu +// +// The **Move to folder** affordance from `/help/documents`: *"Open the +// document, use the Move to folder option (or the document settings menu), +// and choose a destination; select No folder (root) to remove it from all +// folders."* (work-consolidation.md G24.) +// +// One view, two call sites — the document list's per-row context menu and the +// editor's settings menu — so the destination list, the `_templates` +// exclusion, and the "already here" disabling can't drift between them. +// +// Destinations come from `FolderTreeViewModel`, which is already the single +// source for the folder tree; this view adds no fetch of its own. `_templates` +// is absent because `moveDestinations` filters it: it is the server-managed +// folder behind the template picker, and filing an ordinary document there +// would make it show up as a template. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct MoveToFolderMenu: View { + + /// Source of the destination folders. Not fetched here — the sidebar has + /// already loaded the tree. + let folderTree: FolderTreeViewModel + + /// The folder the document is in right now, so that destination can be + /// shown as the current one and disabled rather than silently no-op'ing. + let currentFolderID: FolderNode.ID? + + /// Invoked with the chosen destination. `nil` means "No folder (root)". + let onMove: (FolderNode.ID?) -> Void + + var body: some View { + Menu { + Button { + onMove(nil) + } label: { + Label("No folder (root)", systemImage: "tray") + } + .disabled(currentFolderID == nil) + + let destinations = folderTree.moveDestinations + if !destinations.isEmpty { + Divider() + ForEach(destinations) { folder in + Button { + onMove(folder.id) + } label: { + Label(folder.name, systemImage: "folder") + } + // Moving a document into the folder it already lives in + // would cost a round-trip to change nothing. + .disabled(folder.id == currentFolderID) + } + } + } label: { + Label("Move to Folder", systemImage: "folder.badge.gearshape") + } + .help("File this document in a different folder, or move it out to the root") + } +} diff --git a/App/Features/Documents/PublicUserDocumentsView.swift b/App/Features/Documents/PublicUserDocumentsView.swift new file mode 100644 index 0000000..9613180 --- /dev/null +++ b/App/Features/Documents/PublicUserDocumentsView.swift @@ -0,0 +1,129 @@ +// PublicUserDocumentsView +// +// The documents column on a public profile (work-consolidation.md G24). The +// web profile has a documents tab; macOS had none. +// +// A self-contained section, not a screen: it owns its view model, its loading +// state and its own empty/error copy, so the host profile view adds it with a +// single line and never learns about `DocumentsServicing`. +// +// The rows are intentionally not openable. `GET /api/users/{username}/ +// documents` lists a user's *public* documents but never ships their Markdown, +// and the editor is a first-party surface for documents you own — so this +// column presents titles and dates, and stops there. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct PublicUserDocumentsView: View { + + /// The handle to show documents for. A change re-triggers the load. + let username: String + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: PublicUserDocumentsViewModel? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + if let viewModel { + content(viewModel: viewModel) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .task(id: username) { + // `@Environment` isn't readable during `init`, so the view model is + // built here. `task(id:)` also re-runs when the browsed handle + // changes, which is exactly when a reload is wanted. + guard let environment else { return } + let model = viewModel ?? PublicUserDocumentsViewModel( + documents: environment.documentsService + ) + viewModel = model + await model.load(username: username) + } + } + + // MARK: - Sections + + private var header: some View { + HStack(spacing: 6) { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text("Public documents") + .font(.ilSubtitle()) + if viewModel?.isLoading == true { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading public documents") + } + } + } + + @ViewBuilder + private func content(viewModel: PublicUserDocumentsViewModel) -> some View { + if let error = viewModel.error { + errorState(error: error, viewModel: viewModel) + } else if !viewModel.hasLoaded { + // First load in flight — the header spinner is the only chrome + // needed; a second placeholder would just flicker. + EmptyView() + } else if viewModel.isEmpty { + Text("@\(username) hasn't published any documents.") + .font(.ilBody()) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.documentsLoaded) { document in + PublicDocumentRow(document: document) + if document.id != viewModel.documentsLoaded.last?.id { + Divider() + } + } + } + } + } + + private func errorState( + error: Error, + viewModel: PublicUserDocumentsViewModel + ) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } +} + +// MARK: - PublicDocumentRow + +private struct PublicDocumentRow: View { + + let document: Document + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.ilBody()) + .lineLimit(1) + Text(document.updatedAt.formatted(date: .abbreviated, time: .omitted)) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } +} diff --git a/App/Features/Documents/PublicUserDocumentsViewModel.swift b/App/Features/Documents/PublicUserDocumentsViewModel.swift new file mode 100644 index 0000000..04d1561 --- /dev/null +++ b/App/Features/Documents/PublicUserDocumentsViewModel.swift @@ -0,0 +1,119 @@ +// PublicUserDocumentsViewModel +// +// Drives the documents column on a public profile (work-consolidation.md +// G24 — `GET /api/users/{username}/documents`). The web profile has a +// documents tab; macOS had nothing. +// +// The route is unauthenticated, so this works for any handle and while +// signed out. It is deliberately load-on-demand rather than eager: a profile +// view that fires a second request for every handle typed into the browse +// field would triple the traffic of scanning profiles. +// +// Reads through `DocumentsServicing` only. Per decision 0003, this view model +// consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class PublicUserDocumentsViewModel { + + private let documents: DocumentsServicing + + // MARK: - Observable state + + /// The handle whose documents are loaded, `nil` before the first load. + private(set) var username: String? + + /// The loaded public documents, in server order. + private(set) var documentsLoaded: [Document] = [] + + /// Public folders the route reported. Empty on every account reachable + /// during the G24 probe; surfaced rather than dropped so a future + /// server-side change is visible instead of silently ignored. + private(set) var folders: [FolderNode] = [] + + /// True while a round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load. Cleared on the next + /// successful one. + private(set) var error: Error? + + /// True once a load has completed (successfully or not) for the current + /// handle. Separates "this user has published nothing" from "we have not + /// asked yet", which look identical on `documentsLoaded` alone. + private(set) var hasLoaded: Bool = false + + /// True when the user has published nothing. Only meaningful after + /// `hasLoaded`. + var isEmpty: Bool { + documentsLoaded.isEmpty && folders.isEmpty + } + + // MARK: - Init + + init(documents: DocumentsServicing) { + self.documents = documents + } + + // MARK: - Intents + + /// Loads `username`'s public documents. Refuses a blank handle before the + /// service call — the empty path would resolve to a different route + /// entirely — and resets the previous user's rows so a failed load never + /// shows the last user's documents under the new name. + func load(username: String) async { + let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + error = DocumentsUIError.invalidUsername + hasLoaded = false + documentsLoaded = [] + folders = [] + self.username = nil + return + } + // Switching handles clears the old rows immediately: attributing one + // user's documents to another, even for one frame, is worse than a + // blank column. + if self.username != trimmed { + documentsLoaded = [] + folders = [] + hasLoaded = false + } + self.username = trimmed + isLoading = true + defer { isLoading = false } + do { + let result = try await documents.publicDocuments(ofUser: trimmed) + documentsLoaded = result.documents + folders = result.folders + error = nil + } catch { + // Keep the column empty on failure rather than stale — see above. + documentsLoaded = [] + folders = [] + self.error = error + } + hasLoaded = true + } + + /// Re-runs the load for the currently-loaded handle. No-op before the + /// first successful `load(username:)`. + func refresh() async { + guard let username else { return } + await load(username: username) + } + + /// Drops everything, returning the column to its pre-load state. Called + /// when the host profile view clears its results. + func clear() { + username = nil + documentsLoaded = [] + folders = [] + error = nil + hasLoaded = false + } +} diff --git a/App/Features/Social/ProfileRootView.swift b/App/Features/Social/ProfileRootView.swift index 2555436..e6f8763 100644 --- a/App/Features/Social/ProfileRootView.swift +++ b/App/Features/Social/ProfileRootView.swift @@ -148,12 +148,21 @@ struct ProfileRootView: View { followButton: FollowButtonViewModel? ) -> some View { ScrollView { - ProfileHeaderView( - profile: profile, - counts: counts, - mutuals: mutuals, - followButton: followButton - ) + VStack(alignment: .leading, spacing: 16) { + ProfileHeaderView( + profile: profile, + counts: counts, + mutuals: mutuals, + followButton: followButton + ) + // work-consolidation.md G24 — the documents column the web + // profile has and macOS lacked. Self-contained: it owns its + // view model and its own load, so this stays one line and the + // Social feature learns nothing about `DocumentsServicing`. + Divider() + PublicUserDocumentsView(username: profile.username) + .padding(.horizontal, 16) + } } } diff --git a/App/InterlinedListApp.swift b/App/InterlinedListApp.swift index 738bea2..8924919 100644 --- a/App/InterlinedListApp.swift +++ b/App/InterlinedListApp.swift @@ -212,6 +212,13 @@ private struct AppRootView: View { // `handle` returns `false` for any non-share URL. if ShareLinkDeepLink.handle(url) { return } + // Document invites (work-consolidation.md G24) — a + // `…/documents/invite/{token}` URL routes to the invite landing. + // Ordered after the share handler because the two are disjoint + // (`/shared/` vs `/invite/`), so neither can swallow the other's + // links; `handle` returns `false` for anything else. + if DocumentInviteDeepLink.handle(url) { return } + // `interlinedlist://oauth/callback` is the native OAuth redirect URI // registered in Info.plist (NW-5). ASWebAuthenticationSession intercepts // the URL automatically; this handler is a fallback in case the system diff --git a/App/Navigation/MainWindowView.swift b/App/Navigation/MainWindowView.swift index 9849971..93eb11c 100644 --- a/App/Navigation/MainWindowView.swift +++ b/App/Navigation/MainWindowView.swift @@ -79,6 +79,11 @@ struct MainWindowView: View { // state drives the `ResolveShareView` landing sheet. @State private var pendingShare: ParsedShare? = nil + // Document invites (work-consolidation.md G24) — an opened + // `…/documents/invite/{token}` URL posts `.openDocumentInvite` and this + // state drives the `DocumentInviteView` landing sheet. + @State private var pendingDocumentInvite: ParsedDocumentInvite? = nil + var body: some View { NavigationSplitView { List(selection: $selection) { @@ -246,6 +251,24 @@ struct MainWindowView: View { } } } + // Document invites (work-consolidation.md G24) — an opened invite URL + // posts `.openDocumentInvite`. The landing is read-only: accepting is + // session-cookie-only upstream, so it ends in a browser hand-off and + // there is no post-accept routing to do here. + .onReceive(NotificationCenter.default.publisher(for: .openDocumentInvite)) { note in + guard let parsed = note.object as? ParsedDocumentInvite else { return } + pendingDocumentInvite = parsed + } + .sheet( + isPresented: Binding( + get: { pendingDocumentInvite != nil }, + set: { if !$0 { pendingDocumentInvite = nil } } + ) + ) { + if let parsed = pendingDocumentInvite { + DocumentInviteView(parsed: parsed, environment: environment) + } + } } } diff --git a/AppTests/DocumentInviteViewModelTests.swift b/AppTests/DocumentInviteViewModelTests.swift new file mode 100644 index 0000000..c7cb6a9 --- /dev/null +++ b/AppTests/DocumentInviteViewModelTests.swift @@ -0,0 +1,222 @@ +// DocumentInviteViewModelTests +// +// BDD-named tests for the document invite landing (work-consolidation.md G24) +// and its URL parser. +// +// The most important assertions here are the negative ones: the landing +// resolves and *stops*. `POST /api/documents/invite/{token}` is session-cookie +// only upstream, so this app cannot claim an invite — the view model has no +// accept intent, and the parser must not mistake a share link for an invite. +// +// Stubbed `DocumentsServicing`; no networking, no SwiftUI rendering. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DocumentInviteViewModelTests: XCTestCase { + + private let webBase = URL(string: "https://interlinedlist.com")! + + private func makeViewModel( + _ stub: StubDocumentsService, + token: String = "xN3v9Qk" + ) -> DocumentInviteViewModel { + DocumentInviteViewModel(documents: stub, token: token, webBaseURL: webBase) + } + + // MARK: - Happy path + + func test_givenResolvableInvite_whenResolving_thenShowsTitleRoleAndTheBrowserHandOff() async { + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite( + token: "xN3v9Qk", role: "collaborator", resourceTitle: "Q3 Planning", canClaim: true + )) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.invite?.role, "collaborator") + XCTAssertEqual(viewModel.displayTitle, "Q3 Planning") + XCTAssertEqual(viewModel.nextStep, .acceptInBrowser) + XCTAssertEqual( + viewModel.acceptURL?.absoluteString, + "https://interlinedlist.com/documents/invite/xN3v9Qk" + ) + XCTAssertNil(viewModel.error) + let recorded = await stub.recorded + XCTAssertEqual(recorded.map(\.kind), [.invite(token: "xN3v9Qk")]) + } + + func test_givenAnyResolvedInvite_whenInspectingTheViewModel_thenThereIsNoClaimPath() async { + // The constraint, asserted as behaviour rather than left as a comment: + // every branch ends at a browser hand-off. If someone later adds a + // claim intent, `nextStep` gains a case and this test tells them why + // that route cannot work from a Bearer client. + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite(canClaim: true)) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + let steps: [DocumentInviteViewModel.NextStep] = [ + .alreadyAccepted, .signInInBrowser, .wrongAccount, .acceptInBrowser + ] + XCTAssertTrue(steps.contains(viewModel.nextStep!)) + XCTAssertNotNil(viewModel.acceptURL, "the hand-off is always available once resolved") + } + + // MARK: - Branch selection + + func test_givenSignedOutInvite_whenResolved_thenAsksTheUserToSignInInTheBrowser() async { + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite(needsAuth: true, canClaim: false)) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.nextStep, .signInInBrowser) + } + + func test_givenMismatchedAccount_whenResolved_thenAsksTheUserToSwitchAccounts() async { + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite(canClaim: false, wrongAccount: true)) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.nextStep, .wrongAccount) + } + + func test_givenAlreadyClaimedInvite_whenResolved_thenAlreadyAcceptedWinsOverEveryOtherFlag() async { + // Precedence check: a claimed invite still resolves, and the copy must + // not tell someone to accept something they already have. + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite( + needsAuth: true, canClaim: true, wrongAccount: true, accepted: true + )) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.nextStep, .alreadyAccepted) + } + + // MARK: - Invalid input + + func test_givenBlankToken_whenResolving_thenRefusesBeforeTheService() async { + let stub = StubDocumentsService() + let viewModel = makeViewModel(stub, token: " ") + + await viewModel.resolve() + + XCTAssertEqual(viewModel.error as? DocumentsUIError, .invalidInviteToken) + XCTAssertNil(viewModel.invite) + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - Upstream failure + + func test_givenUnknownOrExpiredToken_whenResolving_thenSurfacesTheErrorAndOffersNoHandOff() async { + // With nothing resolved there is no invite to accept, so the footer + // must not offer a link to a page that will 404 as well. + let stub = StubDocumentsService() + await stub.enqueueInvite(failure: DocumentsError.notFound) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.error as? DocumentsError, .notFound) + XCTAssertNil(viewModel.invite) + XCTAssertNil(viewModel.nextStep) + XCTAssertNil(viewModel.acceptURL) + } + + // MARK: - Empty / boundary + + func test_givenInviteWithoutATitle_whenResolved_thenFallsBackToNeutralCopy() async { + // The server may withhold the title; the landing still has to read. + let stub = StubDocumentsService() + await stub.enqueueInvite(success: DocumentsFixtures.invite(resourceTitle: nil)) + let viewModel = makeViewModel(stub) + + await viewModel.resolve() + + XCTAssertEqual(viewModel.displayTitle, "this document") + } +} + +// MARK: - URL parsing + +final class DocumentInviteURLParserTests: XCTestCase { + + func test_givenWebInviteURL_whenParsed_thenExtractsTheToken() { + // The address the invite email actually carries. + let url = URL(string: "https://interlinedlist.com/documents/invite/xN3v9Qk")! + XCTAssertEqual(DocumentInviteURLParser.parse(url)?.token, "xN3v9Qk") + } + + func test_givenCustomSchemeInviteURL_whenParsed_thenExtractsTheTokenFromTheHostForm() { + // `interlinedlist://documents/invite/tok` parses "documents" as the + // *host*, not a path segment — the case a naive path-only parser drops. + let url = URL(string: "interlinedlist://documents/invite/tok")! + XCTAssertEqual(DocumentInviteURLParser.parse(url)?.token, "tok") + } + + func test_givenShareLinkURL_whenParsed_thenIsNotTreatedAsAnInvite() { + // A share link and an invite are different objects with different + // routes; confusing them would resolve the wrong thing. + let url = URL(string: "https://interlinedlist.com/documents/shared/tok")! + XCTAssertNil(DocumentInviteURLParser.parse(url)) + } + + func test_givenListInviteURL_whenParsed_thenIsNotTreatedAsADocumentInvite() { + // `/lists/invite/{token}` is the list equivalent with its own route. + let url = URL(string: "https://interlinedlist.com/lists/invite/tok")! + XCTAssertNil(DocumentInviteURLParser.parse(url)) + } + + func test_givenTokenlessInviteURL_whenParsed_thenReturnsNil() { + // Boundary: a truncated paste. + XCTAssertNil(DocumentInviteURLParser.parse(URL(string: "https://interlinedlist.com/documents/invite")!)) + } + + func test_givenPastedStringWithWhitespace_whenParsed_thenStillMatches() { + XCTAssertEqual( + DocumentInviteURLParser.parse(string: " https://interlinedlist.com/documents/invite/tok\n")?.token, + "tok" + ) + } + + func test_givenEmptyString_whenParsed_thenReturnsNil() { + XCTAssertNil(DocumentInviteURLParser.parse(string: " ")) + } + + @MainActor + func test_givenInviteURL_whenHandled_thenRoutesItAndReportsHandled() { + var posted: ParsedDocumentInvite? + let handled = DocumentInviteDeepLink.handle( + URL(string: "https://interlinedlist.com/documents/invite/tok")!, + post: { posted = $0 } + ) + + XCTAssertTrue(handled) + XCTAssertEqual(posted?.token, "tok") + } + + @MainActor + func test_givenOAuthCallbackURL_whenHandled_thenFallsThroughUntouched() { + // The URL handler chains invite → share → OAuth; a non-invite URL must + // report `false` so the next handler still sees it. + var posted: ParsedDocumentInvite? + let handled = DocumentInviteDeepLink.handle( + URL(string: "interlinedlist://oauth/callback?code=abc")!, + post: { posted = $0 } + ) + + XCTAssertFalse(handled) + XCTAssertNil(posted) + } +} diff --git a/AppTests/DocumentsTreeAndMoveViewModelTests.swift b/AppTests/DocumentsTreeAndMoveViewModelTests.swift new file mode 100644 index 0000000..47ba4b5 --- /dev/null +++ b/AppTests/DocumentsTreeAndMoveViewModelTests.swift @@ -0,0 +1,348 @@ +// DocumentsTreeAndMoveViewModelTests +// +// BDD-named view-model tests for the work-consolidation.md **G24** App-layer +// behaviour: the sidebar painting from the single tree call, per-folder +// document counts, `_templates` exclusion, moving a document between folders +// (including to root), and New Document routing to the folder route. +// +// Stubbed `DocumentsServicing`; no networking, no SwiftUI rendering. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DocumentsTreeAndMoveViewModelTests: XCTestCase { + + // MARK: - Sidebar: one call + + func test_givenTree_whenInitialLoad_thenPaintsFoldersFromASingleTreeCall() async { + // Happy path. The assertion that matters is not just "folders + // appeared" but that exactly one call — the tree — produced them. + let stub = StubDocumentsService() + await stub.enqueueTree(success: DocumentsFixtures.tree( + folders: [(name: "Inbox", documents: ["Note"]), (name: "Receipts", documents: [])], + rootDocuments: ["Unfiled"] + )) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.folders.map(\.name), ["Inbox", "Receipts"]) + let recorded = await stub.recorded + XCTAssertEqual(recorded.map(\.kind), [.documentTree]) + XCTAssertNil(viewModel.error) + } + + func test_givenNestedFolders_whenInitialLoad_thenProjectsTheHierarchy() async { + let stub = StubDocumentsService() + await stub.enqueueTree(success: DocumentsFixtures.tree( + folders: [(name: "Parent", documents: []), (name: "Child", documents: [])], + parents: ["Child": "Parent"] + )) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.tree.roots.map(\.name), ["Parent"]) + XCTAssertEqual(viewModel.tree.children(of: "F-Parent").map(\.name), ["Child"]) + } + + // MARK: - Sidebar: document counts + + func test_givenTreeWithDocuments_whenLoaded_thenReportsPerFolderAndRootCounts() async { + let stub = StubDocumentsService() + await stub.enqueueTree(success: DocumentsFixtures.tree( + folders: [(name: "Inbox", documents: ["A", "B"]), (name: "Empty", documents: [])], + rootDocuments: ["Unfiled"] + )) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.documentCount(for: "F-Inbox"), 2) + // Boundary: an empty folder reports a real zero, not "unknown". + XCTAssertEqual(viewModel.documentCount(for: "F-Empty"), 0) + XCTAssertEqual(viewModel.documentCount(for: nil), 1) + } + + func test_givenNoTreeYet_whenAskedForACount_thenReportsUnknownRatherThanZero() async { + // Boundary: the difference between "this folder is empty" and "we + // haven't fetched yet". A zero here would be a claim the view model + // has not earned, and the sidebar would render a wrong badge. + let stub = StubDocumentsService() + let viewModel = FolderTreeViewModel(documents: stub) + + XCTAssertNil(viewModel.documentCount(for: "F-Inbox")) + XCTAssertFalse(viewModel.hasTreeSnapshot) + } + + // MARK: - Sidebar: `_templates` + + func test_givenTreeContainingTemplates_whenLoaded_thenItIsNeitherRenderedNorOfferedAsADestination() async { + // Boundary from the issue: `_templates` comes back inline with real + // folders. It is the server's template store — browsing into it or + // filing a document there would be wrong in both directions. + let stub = StubDocumentsService() + await stub.enqueueTree(success: DocumentsFixtures.tree( + folders: [(name: "Inbox", documents: []), (name: "_templates", documents: ["Recipe"])] + )) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.folders.map(\.name), ["Inbox"]) + XCTAssertFalse(viewModel.moveDestinations.contains { $0.name == "_templates" }) + // Still discoverable for the template picker, which does want it. + XCTAssertEqual(viewModel.templatesFolderID, "F-_templates") + } + + func test_givenCachedFoldersIncludingTemplates_whenPaintingFromCache_thenTemplatesIsFilteredThereToo() async { + // The cache is filled by the sync engine, which has no opinion about + // `_templates`. Filtering only the network path would flash the folder + // on screen and then remove it. + let stub = StubDocumentsService() + await stub.setCachedFolders([ + DocumentsFixtures.folder(id: "F1", name: "Inbox"), + DocumentsFixtures.folder(id: "F2", name: "_templates") + ]) + await stub.enqueueTree(failure: TestError.upstream("offline")) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.folders.map(\.name), ["Inbox"]) + } + + // MARK: - Sidebar: upstream failure + + func test_givenCachedFoldersAndFailingTree_whenLoaded_thenKeepsCacheAndFlagsRevalidationFailure() async { + // Upstream-failure case from the issue: the tree call fails → the + // sidebar paints from cache and shows a revalidation error, not an + // empty tree. + let stub = StubDocumentsService() + await stub.setCachedFolders([DocumentsFixtures.folder(id: "F1", name: "Inbox")]) + await stub.enqueueTree(failure: TestError.upstream("tree down")) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.folders.map(\.id), ["F1"], "cached folders must stay on screen") + XCTAssertTrue(viewModel.refreshFailed) + // The blocking error surface stays clear — the sidebar is usable. + XCTAssertNil(viewModel.error) + } + + func test_givenColdCacheAndFailingTree_whenLoaded_thenSurfacesTheErrorWithAnEmptyTree() async { + let stub = StubDocumentsService() + let failure = TestError.upstream("tree down") + await stub.enqueueTree(failure: failure) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertEqual(viewModel.error as? TestError, failure) + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertFalse(viewModel.hasTreeSnapshot) + } + + func test_givenEmptyAccount_whenLoaded_thenTreeIsEmptyWithoutError() async { + let stub = StubDocumentsService() + await stub.enqueueTree(success: DocumentsFixtures.tree()) + let viewModel = FolderTreeViewModel(documents: stub) + + await viewModel.initialLoad() + + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertEqual(viewModel.documentCount(for: nil), 0) + XCTAssertNil(viewModel.error) + } + + // MARK: - Move a document + + func test_givenDocumentInAFolder_whenMovedElsewhere_thenLeavesTheRenderedColumn() async { + // Happy path: the column shows one folder, so a document moved out of + // it no longer belongs there. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: [ + DocumentsFixtures.document(id: "D1", folderId: "F1"), + DocumentsFixtures.document(id: "D2", folderId: "F1") + ]) + await stub.enqueueMove(success: DocumentsFixtures.document(id: "D1", folderId: "F2")) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let moved = await viewModel.moveDocument(id: "D1", to: "F2") + + XCTAssertEqual(moved?.folderId, "F2") + XCTAssertEqual(viewModel.documentsLoaded.map(\.id), ["D2"]) + let recorded = await stub.recorded + XCTAssertTrue(recorded.contains { $0.kind == .moveDocument(id: "D1", toFolder: "F2") }) + } + + func test_givenDocumentInAFolder_whenMovedToRoot_thenSendsANilDestination() async { + // "No folder (root)" — the destination the general update path cannot + // express, so it is worth asserting the nil reaches the service. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: [DocumentsFixtures.document(id: "D1", folderId: "F1")]) + await stub.enqueueMove(success: DocumentsFixtures.document(id: "D1", folderId: nil)) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let moved = await viewModel.moveDocument(id: "D1", to: nil) + + XCTAssertNil(moved?.folderId) + let recorded = await stub.recorded + XCTAssertTrue(recorded.contains { $0.kind == .moveDocument(id: "D1", toFolder: nil) }) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + } + + func test_givenDocumentMovedIntoTheViewedFolder_whenMoved_thenRowStaysAndTakesTheServerCopy() async { + // The mirror of the leaving case. The column shows F1 but is rendering + // a row still marked as root — which happens right after a sync delta. + // Moving it *into* F1 must keep the row and adopt the server's copy, + // not drop a document the user is looking at. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: [DocumentsFixtures.document(id: "D1", folderId: nil)]) + await stub.enqueueMove( + success: DocumentsFixtures.document(id: "D1", folderId: "F1", title: "Server Title") + ) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let moved = await viewModel.moveDocument(id: "D1", to: "F1") + + XCTAssertEqual(moved?.folderId, "F1") + XCTAssertEqual(viewModel.documentsLoaded.map(\.id), ["D1"], "the row must stay in the column") + XCTAssertEqual( + viewModel.documentsLoaded.first?.title, + "Server Title", + "the row must adopt the server's copy, not keep the stale one" + ) + } + + func test_givenDestinationEqualsCurrentFolder_whenMoved_thenNoServiceCallIsMade() async { + // Invalid input: moving a document where it already is. Refused before + // the service so the round-trip isn't spent changing nothing. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: [DocumentsFixtures.document(id: "D1", folderId: "F1")]) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let moved = await viewModel.moveDocument(id: "D1", to: "F1") + + XCTAssertNil(moved) + let recorded = await stub.recorded + XCTAssertFalse(recorded.contains { if case .moveDocument = $0.kind { return true } else { return false } }) + } + + func test_givenUnknownDocumentId_whenMoved_thenNoServiceCallIsMade() async { + // Boundary: the row was removed by a sync delta a moment earlier. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let moved = await viewModel.moveDocument(id: "ghost", to: "F2") + + XCTAssertNil(moved) + let recorded = await stub.recorded + XCTAssertFalse(recorded.contains { if case .moveDocument = $0.kind { return true } else { return false } }) + } + + func test_givenDestinationFolderNoLongerExists_whenMoved_thenRestoresTheRowAndSurfacesTheError() async { + // The issue's invalid case: move to a folder that no longer exists. + // The optimistic removal has to roll back or the user loses sight of a + // document that never moved. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: [ + DocumentsFixtures.document(id: "D1", folderId: "F1"), + DocumentsFixtures.document(id: "D2", folderId: "F1") + ]) + let failure = TestError.upstream("Folder not found") + await stub.enqueueMove(failure: failure) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + viewModel.select(id: "D1") + + let moved = await viewModel.moveDocument(id: "D1", to: "deleted-folder") + + XCTAssertNil(moved) + XCTAssertEqual(viewModel.documentsLoaded.map(\.id), ["D1", "D2"], "the row must come back") + XCTAssertEqual(viewModel.selectedDocumentID, "D1", "the selection must come back too") + XCTAssertEqual(viewModel.error as? TestError, failure) + } + + // MARK: - New Document inside a folder + + func test_givenAFolderIsSelected_whenCreatingADocument_thenUsesTheFolderRouteNotTheRootRoute() async { + // The defect this closes: `POST /api/documents` always creates at root + // and ignores any folderId, so the old call filed nothing in a folder. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + await stub.enqueueCreateInFolder(success: DocumentsFixtures.document(id: "D1", folderId: "F1")) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let created = await viewModel.createDocument(title: "Notes") + + XCTAssertEqual(created?.folderId, "F1") + let recorded = await stub.recorded + XCTAssertTrue(recorded.contains { + $0.kind == .createInFolder( + folderId: "F1", title: "Notes", body: "", isPublic: false, relativePath: nil + ) + }) + // And emphatically *not* the root create. + XCTAssertFalse(recorded.contains { if case .create = $0.kind { return true } else { return false } }) + } + + func test_givenNoFolderSelected_whenCreatingADocument_thenUsesTheRootRoute() async { + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + await stub.enqueueCreate(success: DocumentsFixtures.document(id: "D1", folderId: nil)) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let created = await viewModel.createDocument(title: "Notes") + + XCTAssertNil(created?.folderId) + let recorded = await stub.recorded + XCTAssertTrue(recorded.contains { + $0.kind == .create(title: "Notes", body: "", folderId: nil, isPublic: false) + }) + XCTAssertFalse(recorded.contains { if case .createInFolder = $0.kind { return true } else { return false } }) + } + + func test_givenBlankTitleAndAFolder_whenCreatingADocument_thenRefusesBeforeEitherRoute() async { + // Invalid input: the guard must run before the routing decision, not + // after it. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let created = await viewModel.createDocument(title: " ") + + XCTAssertNil(created) + XCTAssertEqual(viewModel.error as? DocumentsUIError, .invalidDocumentTitle) + let recorded = await stub.recorded + XCTAssertFalse(recorded.contains { if case .createInFolder = $0.kind { return true } else { return false } }) + } + + func test_givenFreeAccount_whenCreatingInAFolder_thenSurfacesTheSubscriberError() async { + // Upstream failure: the subscriber gate. The row must not appear in the + // column, and the error must reach the view. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + await stub.enqueueCreateInFolder(failure: DocumentsError.subscriberRequired) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: "F1") + + let created = await viewModel.createDocument(title: "Notes") + + XCTAssertNil(created) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + XCTAssertEqual(viewModel.error as? DocumentsError, .subscriberRequired) + } +} diff --git a/AppTests/FolderTreeSWRViewModelTests.swift b/AppTests/FolderTreeSWRViewModelTests.swift index 91c7675..c14650f 100644 --- a/AppTests/FolderTreeSWRViewModelTests.swift +++ b/AppTests/FolderTreeSWRViewModelTests.swift @@ -17,7 +17,7 @@ final class FolderTreeSWRViewModelTests: XCTestCase { // Given let stub = StubDocumentsService() await stub.setCachedFolders([DocumentsFixtures.folder(id: "F1", name: "Cached")]) - await stub.enqueueFolders(success: [ + await stub.enqueueTree(foldersOnly: [ DocumentsFixtures.folder(id: "F1", name: "Fresh"), DocumentsFixtures.folder(id: "F2", name: "New") ]) @@ -39,7 +39,7 @@ final class FolderTreeSWRViewModelTests: XCTestCase { func test_givenEmptyCache_whenInitialLoad_thenUsesBlockingSpinnerThenData() async { // Given let stub = StubDocumentsService() - await stub.enqueueFolders(success: [DocumentsFixtures.folder(id: "F1")]) + await stub.enqueueTree(foldersOnly: [DocumentsFixtures.folder(id: "F1")]) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -58,7 +58,7 @@ final class FolderTreeSWRViewModelTests: XCTestCase { // Given let stub = StubDocumentsService() await stub.setCachedFolders([DocumentsFixtures.folder(id: "F1", name: "Cached")]) - await stub.enqueueFolders(failure: TestError.upstream("revalidate down")) + await stub.enqueueTree(failure: TestError.upstream("revalidate down")) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -75,7 +75,7 @@ final class FolderTreeSWRViewModelTests: XCTestCase { func test_givenEmptyCacheAndNetworkFails_whenInitialLoad_thenSurfacesBlockingError() async { // Given let stub = StubDocumentsService() - await stub.enqueueFolders(failure: TestError.upstream("boom")) + await stub.enqueueTree(failure: TestError.upstream("boom")) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -91,7 +91,7 @@ final class FolderTreeSWRViewModelTests: XCTestCase { func test_givenFreshlyRefreshed_whenCheckingShouldRefresh_thenSkipsRevalidation() async { let stub = StubDocumentsService() - await stub.enqueueFolders(success: [DocumentsFixtures.folder(id: "F1")]) + await stub.enqueueTree(foldersOnly: [DocumentsFixtures.folder(id: "F1")]) let viewModel = FolderTreeViewModel(documents: stub) await viewModel.initialLoad() XCTAssertFalse(viewModel.shouldRefresh) diff --git a/AppTests/FolderTreeViewModelTests.swift b/AppTests/FolderTreeViewModelTests.swift index 7478aef..4300179 100644 --- a/AppTests/FolderTreeViewModelTests.swift +++ b/AppTests/FolderTreeViewModelTests.swift @@ -17,7 +17,7 @@ final class FolderTreeViewModelTests: XCTestCase { let stub = StubDocumentsService() let root = DocumentsFixtures.folder(id: "F1", name: "Inbox") let child = DocumentsFixtures.folder(id: "F2", name: "Receipts", parentId: "F1") - await stub.enqueueFolders(success: [root, child]) + await stub.enqueueTree(foldersOnly: [root, child]) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -33,7 +33,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenEmptyResponse_whenInitialLoad_thenLeavesTreeEmpty() async { // Given — empty/boundary case. let stub = StubDocumentsService() - await stub.enqueueFolders(success: []) + await stub.enqueueTree(foldersOnly: []) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -49,7 +49,7 @@ final class FolderTreeViewModelTests: XCTestCase { // Given — upstream API failure. let stub = StubDocumentsService() let failure = TestError.upstream("boom") - await stub.enqueueFolders(failure: failure) + await stub.enqueueTree(failure: failure) let viewModel = FolderTreeViewModel(documents: stub) // When @@ -65,7 +65,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenValidName_whenCreatingFolder_thenAppendsToTree() async { // Happy path. let stub = StubDocumentsService() - await stub.enqueueFolders(success: []) + await stub.enqueueTree(foldersOnly: []) let created = DocumentsFixtures.folder(id: "F1", name: "Inbox") await stub.enqueueCreateFolder(success: created) let viewModel = FolderTreeViewModel(documents: stub) @@ -81,7 +81,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenWhitespaceName_whenCreatingFolder_thenRejectsBeforeService() async { // Invalid-input case. let stub = StubDocumentsService() - await stub.enqueueFolders(success: []) + await stub.enqueueTree(foldersOnly: []) let viewModel = FolderTreeViewModel(documents: stub) await viewModel.initialLoad() @@ -99,7 +99,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenAPIFailure_whenCreatingFolder_thenSurfacesError() async { let stub = StubDocumentsService() - await stub.enqueueFolders(success: []) + await stub.enqueueTree(foldersOnly: []) let failure = TestError.upstream("denied") await stub.enqueueCreateFolder(failure: failure) let viewModel = FolderTreeViewModel(documents: stub) @@ -117,7 +117,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenExistingFolder_whenRenaming_thenSwapsInPlace() async { let stub = StubDocumentsService() let folder = DocumentsFixtures.folder(id: "F1", name: "Old") - await stub.enqueueFolders(success: [folder]) + await stub.enqueueTree(foldersOnly: [folder]) let renamed = DocumentsFixtures.folder(id: "F1", name: "New") await stub.enqueueRenameFolder(success: renamed) let viewModel = FolderTreeViewModel(documents: stub) @@ -132,7 +132,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenRenameFailure_whenRenaming_thenRestoresSnapshot() async { let stub = StubDocumentsService() let folder = DocumentsFixtures.folder(id: "F1", name: "Old") - await stub.enqueueFolders(success: [folder]) + await stub.enqueueTree(foldersOnly: [folder]) let failure = TestError.upstream("denied") await stub.enqueueRenameFolder(failure: failure) let viewModel = FolderTreeViewModel(documents: stub) @@ -149,7 +149,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenDeletedFolder_whenDeleting_thenRemovesAndClearsSelection() async { let stub = StubDocumentsService() let folder = DocumentsFixtures.folder(id: "F1", name: "Inbox") - await stub.enqueueFolders(success: [folder]) + await stub.enqueueTree(foldersOnly: [folder]) await stub.enqueueDeleteFolderSuccess() let viewModel = FolderTreeViewModel(documents: stub) await viewModel.initialLoad() @@ -165,7 +165,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenDeleteFailure_whenDeleting_thenRestoresSnapshotAndSurfacesError() async { let stub = StubDocumentsService() let folder = DocumentsFixtures.folder(id: "F1", name: "Inbox") - await stub.enqueueFolders(success: [folder]) + await stub.enqueueTree(foldersOnly: [folder]) let failure = TestError.upstream("denied") await stub.enqueueDeleteFolder(failure: failure) let viewModel = FolderTreeViewModel(documents: stub) @@ -181,7 +181,7 @@ final class FolderTreeViewModelTests: XCTestCase { func test_givenSelectedID_whenSelecting_thenUpdatesProperty() async { let stub = StubDocumentsService() - await stub.enqueueFolders(success: []) + await stub.enqueueTree(foldersOnly: []) let viewModel = FolderTreeViewModel(documents: stub) await viewModel.initialLoad() diff --git a/AppTests/LaunchPrefetchCoordinatorTests.swift b/AppTests/LaunchPrefetchCoordinatorTests.swift index 7be7103..ac69eb6 100644 --- a/AppTests/LaunchPrefetchCoordinatorTests.swift +++ b/AppTests/LaunchPrefetchCoordinatorTests.swift @@ -101,7 +101,7 @@ final class LaunchPrefetchCoordinatorTests: XCTestCase { ])) let documents = StubDocumentsService() - await documents.enqueueFolders(success: [DocumentsFixtures.folder(id: "F9")]) + await documents.enqueueTree(foldersOnly: [DocumentsFixtures.folder(id: "F9")]) await documents.enqueueDocuments(success: [DocumentsFixtures.document(id: "D9")]) let messages = StubMessagesService() diff --git a/AppTests/PublicUserDocumentsViewModelTests.swift b/AppTests/PublicUserDocumentsViewModelTests.swift new file mode 100644 index 0000000..c4135d2 --- /dev/null +++ b/AppTests/PublicUserDocumentsViewModelTests.swift @@ -0,0 +1,141 @@ +// PublicUserDocumentsViewModelTests +// +// BDD-named view-model tests for the profile's public-documents column +// (work-consolidation.md G24 — `GET /api/users/{username}/documents`). +// +// Stubbed `DocumentsServicing`; no networking, no SwiftUI rendering. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class PublicUserDocumentsViewModelTests: XCTestCase { + + // MARK: - Happy path + + func test_givenUserWithPublicDocuments_whenLoading_thenPaintsThemForThatHandle() async { + let stub = StubDocumentsService() + await stub.enqueuePublicDocuments(success: PublicUserDocuments( + username: "adron", + documents: [ + DocumentsFixtures.document(id: "D1", title: "Railroad Apps"), + DocumentsFixtures.document(id: "D2", title: "Shows") + ] + )) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.load(username: "adron") + + XCTAssertEqual(viewModel.documentsLoaded.map(\.id), ["D1", "D2"]) + XCTAssertEqual(viewModel.username, "adron") + XCTAssertTrue(viewModel.hasLoaded) + XCTAssertFalse(viewModel.isEmpty) + XCTAssertNil(viewModel.error) + let recorded = await stub.recorded + XCTAssertEqual(recorded.map(\.kind), [.publicDocuments(username: "adron")]) + } + + func test_givenHandleWithSurroundingWhitespace_whenLoading_thenTrimsBeforeCalling() async { + let stub = StubDocumentsService() + await stub.enqueuePublicDocuments(success: PublicUserDocuments(username: "adron")) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.load(username: " adron\n") + + let recorded = await stub.recorded + XCTAssertEqual(recorded.map(\.kind), [.publicDocuments(username: "adron")]) + } + + // MARK: - Invalid input + + func test_givenBlankHandle_whenLoading_thenRefusesBeforeTheService() async { + // `/api/users//documents` is a different route entirely, so the guard + // has to run here rather than being left to the server. + let stub = StubDocumentsService() + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.load(username: " ") + + XCTAssertEqual(viewModel.error as? DocumentsUIError, .invalidUsername) + XCTAssertFalse(viewModel.hasLoaded) + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - Upstream failure + + func test_givenAPIFailure_whenLoading_thenSurfacesTheErrorWithNoRows() async { + let stub = StubDocumentsService() + let failure = TestError.upstream("boom") + await stub.enqueuePublicDocuments(failure: failure) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.load(username: "adron") + + XCTAssertEqual(viewModel.error as? TestError, failure) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + XCTAssertTrue(viewModel.hasLoaded) + } + + func test_givenASecondHandleThatFails_whenLoading_thenDoesNotShowTheFirstUsersDocuments() async { + // Attributing one person's documents to another — even briefly — is + // worse than showing nothing, so a failed switch clears the column. + let stub = StubDocumentsService() + await stub.enqueuePublicDocuments(success: PublicUserDocuments( + username: "adron", + documents: [DocumentsFixtures.document(id: "D1")] + )) + await stub.enqueuePublicDocuments(failure: TestError.upstream("boom")) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + await viewModel.load(username: "adron") + + await viewModel.load(username: "someone-else") + + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + XCTAssertEqual(viewModel.username, "someone-else") + } + + // MARK: - Empty / boundary + + func test_givenUserWithNothingPublic_whenLoading_thenReportsEmptyRatherThanAnError() async { + let stub = StubDocumentsService() + await stub.enqueuePublicDocuments(success: PublicUserDocuments(username: "ghost")) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.load(username: "ghost") + + XCTAssertTrue(viewModel.isEmpty) + XCTAssertTrue(viewModel.hasLoaded, "empty must be distinguishable from not-yet-asked") + XCTAssertNil(viewModel.error) + } + + func test_givenNoLoadYet_whenAskedToRefresh_thenDoesNothing() async { + // Boundary: `refresh()` before any handle has been browsed. + let stub = StubDocumentsService() + let viewModel = PublicUserDocumentsViewModel(documents: stub) + + await viewModel.refresh() + + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertFalse(viewModel.hasLoaded) + } + + func test_givenLoadedDocuments_whenCleared_thenReturnsToThePreLoadState() async { + let stub = StubDocumentsService() + await stub.enqueuePublicDocuments(success: PublicUserDocuments( + username: "adron", + documents: [DocumentsFixtures.document(id: "D1")] + )) + let viewModel = PublicUserDocumentsViewModel(documents: stub) + await viewModel.load(username: "adron") + + viewModel.clear() + + XCTAssertNil(viewModel.username) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + XCTAssertFalse(viewModel.hasLoaded) + XCTAssertNil(viewModel.error) + } +} diff --git a/AppTests/Support/StubDocumentsService.swift b/AppTests/Support/StubDocumentsService.swift index e6029be..ee66aa5 100644 --- a/AppTests/Support/StubDocumentsService.swift +++ b/AppTests/Support/StubDocumentsService.swift @@ -15,6 +15,11 @@ struct RecordedDocumentsCall: Sendable, Equatable { case documents(folderId: String?, limit: Int, offset: Int) case document(id: String) case create(title: String, body: String, folderId: String?, isPublic: Bool) + case createInFolder(folderId: String, title: String, body: String, isPublic: Bool, relativePath: String?) + case moveDocument(id: String, toFolder: String?) + case documentTree + case publicDocuments(username: String) + case invite(token: String) case update(id: String, title: String?, body: String?, folderId: String?, isPublic: Bool?) case delete(id: String) case uploadImage(documentId: String, byteCount: Int, suggestedName: String?) @@ -36,6 +41,11 @@ actor StubDocumentsService: DocumentsServicing { private var documentsOutcomes: [Result<[Document], Error>] = [] private var documentOutcomes: [Result] = [] private var createOutcomes: [Result] = [] + private var createInFolderOutcomes: [Result] = [] + private var moveOutcomes: [Result] = [] + private var treeOutcomes: [Result] = [] + private var publicDocumentsOutcomes: [Result] = [] + private var inviteOutcomes: [Result] = [] private var updateOutcomes: [Result] = [] private var deleteOutcomes: [Result] = [] private var uploadImageOutcomes: [Result] = [] @@ -66,6 +76,28 @@ actor StubDocumentsService: DocumentsServicing { func enqueueCreate(success doc: Document) { createOutcomes.append(.success(doc)) } func enqueueCreate(failure error: Error) { createOutcomes.append(.failure(error)) } + func enqueueCreateInFolder(success doc: Document) { createInFolderOutcomes.append(.success(doc)) } + func enqueueCreateInFolder(failure error: Error) { createInFolderOutcomes.append(.failure(error)) } + + func enqueueMove(success doc: Document) { moveOutcomes.append(.success(doc)) } + func enqueueMove(failure error: Error) { moveOutcomes.append(.failure(error)) } + + func enqueueTree(success tree: DocumentTreeSnapshot) { treeOutcomes.append(.success(tree)) } + func enqueueTree(failure error: Error) { treeOutcomes.append(.failure(error)) } + + /// Programs a tree that carries `folders` and no documents. The sidebar + /// reads folders from the tree since G24, so tests that only care about + /// the folder list say so without building a document index. + func enqueueTree(foldersOnly folders: [FolderNode]) { + treeOutcomes.append(.success(DocumentTreeSnapshot(folders: folders))) + } + + func enqueuePublicDocuments(success result: PublicUserDocuments) { publicDocumentsOutcomes.append(.success(result)) } + func enqueuePublicDocuments(failure error: Error) { publicDocumentsOutcomes.append(.failure(error)) } + + func enqueueInvite(success invite: DocumentInvite) { inviteOutcomes.append(.success(invite)) } + func enqueueInvite(failure error: Error) { inviteOutcomes.append(.failure(error)) } + func enqueueUpdate(success doc: Document) { updateOutcomes.append(.success(doc)) } func enqueueUpdate(failure error: Error) { updateOutcomes.append(.failure(error)) } @@ -123,6 +155,43 @@ actor StubDocumentsService: DocumentsServicing { return try take(&createOutcomes, label: "create") } + func createDocument( + inFolder folderId: String, + title: String, + body: String, + isPublic: Bool, + relativePath: String? + ) async throws -> Document { + recorded.append(.init(kind: .createInFolder( + folderId: folderId, + title: title, + body: body, + isPublic: isPublic, + relativePath: relativePath + ))) + return try take(&createInFolderOutcomes, label: "createDocument(inFolder:)") + } + + func moveDocument(id: String, toFolder folderId: String?) async throws -> Document { + recorded.append(.init(kind: .moveDocument(id: id, toFolder: folderId))) + return try take(&moveOutcomes, label: "moveDocument") + } + + func documentTree() async throws -> DocumentTreeSnapshot { + recorded.append(.init(kind: .documentTree)) + return try take(&treeOutcomes, label: "documentTree") + } + + func publicDocuments(ofUser username: String) async throws -> PublicUserDocuments { + recorded.append(.init(kind: .publicDocuments(username: username))) + return try take(&publicDocumentsOutcomes, label: "publicDocuments") + } + + func invite(token: String) async throws -> DocumentInvite { + recorded.append(.init(kind: .invite(token: token))) + return try take(&inviteOutcomes, label: "invite") + } + func update(id: String, title: String?, body: String?, folderId: String?, isPublic: Bool?) async throws -> Document { recorded.append(.init(kind: .update(id: id, title: title, body: body, folderId: folderId, isPublic: isPublic))) return try take(&updateOutcomes, label: "update") @@ -225,6 +294,62 @@ enum DocumentsFixtures { ) } + /// A `DocumentTreeSnapshot` from a compact `[folderName: [documentTitle]]` + /// description, so a test can say what shape it wants without hand-building + /// the folder / summary graph. + /// + /// Folder ids are derived from the name (`"Inbox"` → `"F-Inbox"`) and + /// document ids from the title, so assertions can name them directly. + /// `parents` re-parents a folder to build a nested tree. + static func tree( + folders: [(name: String, documents: [String])] = [], + rootDocuments: [String] = [], + parents: [String: String] = [:] + ) -> DocumentTreeSnapshot { + var nodes: [FolderNode] = [] + var index: [String: [DocumentSummary]] = [:] + for entry in folders { + let id = "F-\(entry.name)" + nodes.append( + FolderNode( + id: id, + parentId: parents[entry.name].map { "F-\($0)" }, + name: entry.name + ) + ) + index[id] = entry.documents.map { + DocumentSummary(id: "D-\($0)", title: $0, folderId: id) + } + } + return DocumentTreeSnapshot( + folders: nodes, + documentsByFolder: index, + rootDocuments: rootDocuments.map { + DocumentSummary(id: "D-\($0)", title: $0, folderId: nil) + } + ) + } + + static func invite( + token: String = "tok", + role: String = "collaborator", + resourceTitle: String? = "Q3 Planning", + needsAuth: Bool = false, + canClaim: Bool = true, + wrongAccount: Bool = false, + accepted: Bool = false + ) -> DocumentInvite { + DocumentInvite( + token: token, + role: role, + resourceTitle: resourceTitle, + needsAuth: needsAuth, + canClaim: canClaim, + wrongAccount: wrongAccount, + accepted: accepted + ) + } + static func emptyReport(lastSyncAt: Date? = nil) -> DocumentSyncReport { DocumentSyncReport(lastSyncAt: lastSyncAt) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentMappers.swift index b8050d4..c65656e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentMappers.swift @@ -121,3 +121,107 @@ extension DocumentSyncOperation { } } } + +// MARK: - Sidebar tree (work-consolidation.md G24) + +extension FolderNode { + + /// Maps a `DocumentTreeFolderDTO`. The tree's folder rows are thinner than + /// `DocumentFolderDTO`: no `createdAt` / `updatedAt` and no `deleted` + /// tombstone (the tree only ever returns live folders). Those project to + /// `nil` / `false` rather than being invented. + public init(from dto: DocumentTreeFolderDTO) { + self.init( + id: dto.id, + parentId: dto.parentId, + name: dto.name, + createdAt: nil, + updatedAt: nil, + deleted: false + ) + } +} + +extension DocumentSummary { + + /// Maps a document row from the tree or the public-by-user route. + /// + /// `folderID` is passed in rather than read off the DTO because the tree + /// nests documents under their folder and omits `folderId` entirely; the + /// caller supplies the enclosing folder (or `nil` for `rootDocuments`). + /// When the DTO *does* carry a `folderId` (the public-by-user route), that + /// value wins — the caller has no better information there. + public init(from dto: DocumentDTO, folderID: FolderNode.ID?) { + self.init( + id: dto.id, + title: dto.title, + folderId: dto.folderId ?? folderID, + relativePath: dto.relativePath, + isPublic: dto.isPublic ?? false + ) + } +} + +extension DocumentTreeSnapshot { + + /// Maps `GET /api/documents/tree` into the sidebar snapshot. + /// + /// Root documents stay a separate array — flattening them into a synthetic + /// folder would erase the "no folder" destination the move action needs. + /// `_templates` is kept in `folders` (the picker looks it up by name) and + /// filtered out of the user-facing projections on the snapshot itself. + public init(from dto: DocumentTreeResponse) { + var index: [FolderNode.ID: [DocumentSummary]] = [:] + for folder in dto.folders { + index[folder.id] = folder.documents.map { + DocumentSummary(from: $0, folderID: folder.id) + } + } + self.init( + folders: dto.folders.map(FolderNode.init(from:)), + documentsByFolder: index, + rootDocuments: dto.rootDocuments.map { + DocumentSummary(from: $0, folderID: nil) + } + ) + } +} + +// MARK: - Public documents by user (work-consolidation.md G24) + +extension PublicUserDocuments { + + /// Maps `GET /api/users/{username}/documents`. The username is not in the + /// response body — it is the path parameter — so the caller passes it back + /// in for display. + public init(username: String, from dto: PublicUserDocumentsResponse) { + self.init( + username: username, + documents: dto.documents.map(Document.init(from:)), + folders: dto.folders.map(FolderNode.init(from:)) + ) + } +} + +// MARK: - Invite landing (work-consolidation.md G24) + +extension DocumentInvite { + + /// Maps `GET /api/documents/invite/{token}`. Every branch flag is optional + /// on the wire and collapses to `false` here: an absent flag means "this + /// branch does not apply", which is exactly what `false` renders as. + /// + /// The token is not echoed in the body, so it is threaded through from the + /// request — the landing view needs it to build the browser hand-off URL. + public init(token: String, from dto: DocumentInviteLandingDTO) { + self.init( + token: token, + role: dto.role, + resourceTitle: dto.resourceTitle, + needsAuth: dto.needsAuth ?? false, + canClaim: dto.canClaim ?? false, + wrongAccount: dto.wrongAccount ?? false, + accepted: dto.accepted ?? false + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTreeSnapshot.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTreeSnapshot.swift new file mode 100644 index 0000000..9043cb7 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTreeSnapshot.swift @@ -0,0 +1,252 @@ +import Foundation + +// MARK: - DocumentSummary + +/// A document as the sidebar tree knows it (work-consolidation.md G24). +/// +/// `GET /api/documents/tree` returns lighter document rows than +/// `GET /api/documents` does — `id`, `title`, `relativePath` and `isPublic`, +/// and nothing else. Modelling that as a `Document` would be a lie: every +/// consumer would see an `updatedAt` of `.distantPast` and an empty body and +/// have no way to tell "the server said empty" from "this shape doesn't carry +/// it". `DocumentSummary` is the honest projection — enough to render a row, +/// count a folder, or name a move source, and deliberately not enough to open +/// an editor. +/// +/// `folderId` is *derived*, not transported: the tree nests documents under +/// their folder and omits the field, so the mapper stamps the enclosing +/// folder's id (and `nil` for `rootDocuments`). +public struct DocumentSummary: Sendable, Equatable, Hashable, Identifiable { + + public let id: String + public let title: String + /// The folder this document is filed in; `nil` means root (unfiled). + public let folderId: String? + /// The document's path within the synced folder, when the server sent one. + public let relativePath: String? + public let isPublic: Bool + + public init( + id: String, + title: String, + folderId: String? = nil, + relativePath: String? = nil, + isPublic: Bool = false + ) { + self.id = id + self.title = title + self.folderId = folderId + self.relativePath = relativePath + self.isPublic = isPublic + } +} + +// MARK: - DocumentTreeSnapshot + +/// The whole documents sidebar as one value (work-consolidation.md G24). +/// +/// Domain projection of `GET /api/documents/tree`. Holds the flat folder list +/// (nesting lives in `FolderNode.parentId`, exactly as before) plus the +/// folder → documents index and the unfiled root documents. +/// +/// Two invariants the wire shape forces and this type preserves: +/// +/// 1. **Root documents are not a folder.** `rootDocuments` stays a distinct +/// array; "no folder" is a real destination the UI must be able to name, +/// so it is never folded into `folders`. +/// 2. **`_templates` is a real folder in the payload.** The server returns the +/// template folder inline with the user's own folders. `userFolders` and +/// `moveDestinations` filter it out; `folders` keeps it so callers that do +/// want it (the template picker) can still find it via `templatesFolderID`. +public struct DocumentTreeSnapshot: Sendable, Equatable { + + /// The name the server gives the folder that backs the template picker. + /// Documented on `/help/documents`: *"Templates are stored in a special + /// folder named `_templates`, created automatically the first time you + /// open the template picker."* + public static let templatesFolderName = "_templates" + + /// Every folder in the account, including `_templates`, in server order. + public let folders: [FolderNode] + + /// Documents filed in each folder, keyed by folder id. + public let documentsByFolder: [FolderNode.ID: [DocumentSummary]] + + /// Documents with no folder. + public let rootDocuments: [DocumentSummary] + + public init( + folders: [FolderNode] = [], + documentsByFolder: [FolderNode.ID: [DocumentSummary]] = [:], + rootDocuments: [DocumentSummary] = [] + ) { + self.folders = folders + self.documentsByFolder = documentsByFolder + self.rootDocuments = rootDocuments + } + + // MARK: - Derived views + + /// The folders a person should see and be offered, i.e. everything except + /// the machine-managed `_templates` folder. + public var userFolders: [FolderNode] { + folders.filter { $0.name != Self.templatesFolderName } + } + + /// The id of the `_templates` folder, when the account has one yet. `nil` + /// before the first template-picker visit creates it. + public var templatesFolderID: FolderNode.ID? { + folders.first { $0.name == Self.templatesFolderName }?.id + } + + /// The folders offered as a **Move to folder** destination: the user's own + /// folders, never `_templates`. "No folder (root)" is represented by `nil` + /// at the call site rather than by a synthetic entry here. + public var moveDestinations: [FolderNode] { + userFolders + } + + /// The sidebar projection over `userFolders`. `_templates` is excluded so + /// it never renders as a browsable folder. + public var folderTree: FolderTree { + FolderTree(folders: userFolders) + } + + /// The documents in `folderID`, or the root documents when `nil`. + /// Empty for a folder the snapshot doesn't know or one with no documents — + /// the two are indistinguishable here by design, since an unknown folder + /// has nothing to show either way. + public func documents(in folderID: FolderNode.ID?) -> [DocumentSummary] { + guard let folderID else { return rootDocuments } + return documentsByFolder[folderID] ?? [] + } + + /// How many documents are filed directly in `folderID` (or at root when + /// `nil`). Direct children only — a parent folder does not count the + /// documents inside its sub-folders. + public func documentCount(in folderID: FolderNode.ID?) -> Int { + documents(in: folderID).count + } + + /// Every document in the snapshot, root documents first then each folder's + /// in folder order. Used to answer "which folder is this document in?" + /// without the caller rebuilding the index. + public var allDocuments: [DocumentSummary] { + var all = rootDocuments + for folder in folders { + all.append(contentsOf: documentsByFolder[folder.id] ?? []) + } + return all + } + + /// The folder holding `documentID`, or `nil` when the document is at root + /// **or** absent from the snapshot. Callers that need to tell those apart + /// should check `allDocuments` first. + public func folderID(ofDocument documentID: String) -> FolderNode.ID? { + allDocuments.first { $0.id == documentID }?.folderId + } +} + +// MARK: - PublicUserDocuments + +/// A user's public documents (work-consolidation.md G24 — +/// `GET /api/users/{username}/documents`). +/// +/// These rows are richer than the tree's — they carry `createdAt` / +/// `updatedAt` — so they project to full `Document` values, with an empty +/// body: the route lists documents but never ships their Markdown. Fetch the +/// document by id to read it. +public struct PublicUserDocuments: Sendable, Equatable { + + /// The username these documents belong to, echoed back for display. + public let username: String + + /// The public documents, in server order. + public let documents: [Document] + + /// Public folders, when the account exposes any. Empty on every account + /// reachable read-only during the G24 probe, so treat a non-empty value as + /// unverified-but-tolerated rather than as a load-bearing contract. + public let folders: [FolderNode] + + public init( + username: String, + documents: [Document] = [], + folders: [FolderNode] = [] + ) { + self.username = username + self.documents = documents + self.folders = folders + } + + /// True when the user has published nothing — the profile column's empty + /// state, distinct from a failed load. + public var isEmpty: Bool { + documents.isEmpty && folders.isEmpty + } +} + +// MARK: - DocumentInvite + +/// A resolved document email invite (work-consolidation.md G24 — +/// `GET /api/documents/invite/{token}`). +/// +/// The landing state, and only the landing state. **Accepting is not +/// modelled** and cannot be: `POST /api/documents/invite/{token}` is +/// session-cookie-authenticated in the live spec, so a Bearer sync-token +/// client has no way to claim. `acceptURL(base:)` builds the web address the +/// Mac hands to the browser instead. +public struct DocumentInvite: Sendable, Equatable { + + /// The opaque invite token this was resolved from. + public let token: String + + /// The role the invite grants (`watcher` / `collaborator` / `manager`). + public let role: String + + /// The document's title, for display. `nil` when the server withheld it. + public let resourceTitle: String? + + /// No user is signed in on the *web* session → the landing page prompts + /// sign-in before the invite can be claimed. + public let needsAuth: Bool + + /// The signed-in web user's verified email matches the invited address, so + /// the accept step will succeed once they take it in the browser. + public let canClaim: Bool + + /// Someone is signed in on the web under a different address; they must + /// switch accounts before accepting. + public let wrongAccount: Bool + + /// The invite was already claimed. It still resolves so the landing page + /// can link the claimer into the document. + public let accepted: Bool + + public init( + token: String, + role: String, + resourceTitle: String? = nil, + needsAuth: Bool = false, + canClaim: Bool = false, + wrongAccount: Bool = false, + accepted: Bool = false + ) { + self.token = token + self.role = role + self.resourceTitle = resourceTitle + self.needsAuth = needsAuth + self.canClaim = canClaim + self.wrongAccount = wrongAccount + self.accepted = accepted + } + + /// The web address that completes the invite. Accepting is browser-only + /// (session cookie), so this is the Mac's hand-off, not a fallback. + public func acceptURL(base: URL) -> URL { + base + .appendingPathComponent("documents") + .appendingPathComponent("invite") + .appendingPathComponent(token) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift index 307c22a..503a3c5 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift @@ -21,6 +21,25 @@ public enum DocumentsError: Error, Sendable, Equatable { /// on `DocumentsError`, not the imaging error. case imageTooLargeAfterPrep + /// A subscriber-only documents action was attempted by a free account. + /// Raised **before** any HTTP call so the UI can gate the affordance + /// rather than surfacing a bare 403. Carries nothing: the only gated + /// documents action today is creating a document, and the message is the + /// same whichever route it came in on. + /// + /// TODO(#40): issue #40 owns `EntitlementsService` and is building a + /// `CapabilityGate` whose denials carry a reason and run + /// status → email-verification → tier, hardest first. When it lands, this + /// case should carry that denial instead of standing alone, and the gate + /// below should ask it rather than reading `isSubscriber`. Deliberately not + /// done here: adding a `Feature` case means editing the file #40 owns, so + /// this consumes the existing seam and leaves the enum untouched. + /// + /// Note the gate matches #40's published matrix: **creation only**. Moving, + /// editing and deleting documents stay free on every tier, so a lapsed + /// subscriber keeps existing content fully usable. + case subscriberRequired + /// The sync engine refused to complete a cycle. Carries the underlying /// transport / API failure unchanged so the UI can still inspect it. /// Wrapped as `APIError` when the underlying source was one, or @@ -40,6 +59,8 @@ extension DocumentsError: LocalizedError, CustomStringConvertible { return "Document \(localId) is out of date (server version: \(serverVersion))." case .imageTooLargeAfterPrep: return "Image is too large to upload, even after compression." + case .subscriberRequired: + return "Creating documents requires a subscription." case .syncFailed(let underlying): return "Document sync failed: \(underlying.localizedDescription)" } @@ -113,6 +134,67 @@ public protocol DocumentsServicing: Sendable { /// byte budget. func uploadImage(in documentId: String, image: Data, suggestedName: String?) async throws -> URL + /// Creates a document **inside** `folderId` via + /// `POST /api/documents/folders/{id}/documents`. + /// + /// A separate method rather than a flag on `create(...)` because it is a + /// different route with a different contract: `POST /api/documents` + /// documents itself as "always creates at root — there is no `folderId` + /// in its body", so the `folderId` argument on `create(...)` has never + /// reached the server. Anything filing a document into a folder must come + /// through here. + /// + /// Subscriber-gated (`x-subscription-tier: subscriber` on the live spec). + /// The gate runs locally first and throws + /// `DocumentsError.subscriberRequired` before any HTTP call. + func createDocument( + inFolder folderId: String, + title: String, + body: String, + isPublic: Bool, + relativePath: String? + ) async throws -> Document + + /// Moves a document into `folderId`, or out to root when `folderId` is + /// `nil`. Returns the relocated document as the server sees it. + /// + /// Distinct from `update(id:…folderId:…)` because only this path can + /// express "no folder": `UpdateDocumentRequest` omits nil keys, and an + /// omitted `folderId` means "leave it where it is". + func moveDocument(id: String, toFolder folderId: String?) async throws -> Document + + // MARK: - Sidebar tree + + /// The whole documents sidebar in one call (`GET /api/documents/tree`): + /// every folder with its documents inline, plus the unfiled root + /// documents. + /// + /// This is the sidebar's single source. It replaces `folders(limit:offset:)` + /// there, and it write-throughs its folders to the injected `DocumentStore` + /// so `cachedFolders()` keeps serving the same stale-while-revalidate paint + /// it did before. + /// + /// It does **not** replace `documents(in:limit:offset:)`: the tree's inline + /// rows carry no body and no `updatedAt`, so the document list column and + /// the editor still need the heavier read. Only the *folder* fetch retires. + func documentTree() async throws -> DocumentTreeSnapshot + + // MARK: - Public documents + + /// A user's public documents (`GET /api/users/{username}/documents`). + /// Unauthenticated — usable for any handle, including while signed out. + func publicDocuments(ofUser username: String) async throws -> PublicUserDocuments + + // MARK: - Invites + + /// Resolves a document email invite for its landing page + /// (`GET /api/documents/invite/{token}`). + /// + /// Landing only. There is no accept method and cannot be one: the claim + /// route is session-cookie-authenticated, so a Bearer client hands the + /// final step to the browser via `DocumentInvite.acceptURL(base:)`. + func invite(token: String) async throws -> DocumentInvite + // MARK: - Folders func folders(limit: Int, offset: Int) async throws -> [FolderNode] @@ -148,6 +230,25 @@ public final class DocumentsService: DocumentsServicing { /// image ceilings; when `nil` the built-in `ImagePrep` constants apply. private let contentLimits: ContentLimitsProviding? + /// Live entitlements, evaluated at call time on every gated write. + /// + /// A closure, not a stored value, for the same reason `MessagesService` + /// uses one: the signed-in account's `customerStatus` changes mid-session + /// (sign-in resolves, a subscription lapses, a 403 forces a re-fetch), and + /// a snapshot taken at launch would gate on a stale answer. The App layer + /// passes a reader over its `LiveEntitlements` box. + /// + /// Defaults to `.free` — a signed-out or unresolved session is never + /// wrongly entitled. + /// + /// TODO(#40): the gate reads `EntitlementsService.isSubscriber` directly + /// because `Feature` has no documents case yet. Issue #40 owns + /// `EntitlementsService`; when its `CapabilityGate` lands, add a + /// `Feature.documentCreation` case (named for creation, not `.documents`, + /// to keep it unmissable that only creation is gated) and switch + /// `requireSubscriber()` below to ask the gate. One line, no call sites. + private let entitlementsProvider: @Sendable () -> EntitlementsService + /// - Parameters: /// - api: networking seam (a stub in tests). /// - sync: optional coordinator. When `nil`, sync methods throw and @@ -167,13 +268,17 @@ public final class DocumentsService: DocumentsServicing { sync: DocumentSyncCoordinating? = nil, store: DocumentStore? = nil, decoder: JSONDecoder = JSONCoders.makeDecoder(), - contentLimits: ContentLimitsProviding? = nil + contentLimits: ContentLimitsProviding? = nil, + entitlementsProvider: @escaping @Sendable () -> EntitlementsService = { + EntitlementsService(customerStatus: .free) + } ) { self.api = api self.sync = sync self.store = store self.decoder = decoder self.contentLimits = contentLimits + self.entitlementsProvider = entitlementsProvider } // MARK: - Documents @@ -278,6 +383,130 @@ public final class DocumentsService: DocumentsServicing { } } + public func createDocument( + inFolder folderId: String, + title: String, + body: String, + isPublic: Bool, + relativePath: String? + ) async throws -> Document { + // Gate before the round-trip: a free account gets a typed domain error + // and a useful message instead of a bare 403 from the wire. + try requireSubscriber() + let req = CreateDocumentInFolderRequest( + title: title, + content: body, + relativePath: relativePath, + isPublic: isPublic + ) + do { + // Same `{ message, document }` envelope as `POST /api/documents`. + let dto = try await api.send(Documents.createInFolder(folderId: folderId, req)).document + let document = Document(from: dto) + // Write through so the folder's cached documents include the new + // row before the next revalidation — same contract as `documents`. + if let store { + await store.upsert(document, localEditedAt: nil) + } + return document + } catch let error as APIError { + // A folder that vanished between the sidebar painting it and the + // create landing reads as "not found" to the user, not as a raw + // status code. + if case .notFound = error { + throw DocumentsError.notFound + } + // The server gates this route too. Translate its 403 into the same + // typed error the local gate raises so callers branch once. + if case .forbidden = error { + throw DocumentsError.subscriberRequired + } + throw error + } + } + + public func moveDocument(id: String, toFolder folderId: String?) async throws -> Document { + do { + // `Documents.move` encodes an explicit `null` for root; a plain + // `update(folderId: nil)` would omit the key and move nothing. + let dto = try await api.send(Documents.move(id: id, toFolderId: folderId)).document + let document = Document(from: dto) + if let store { + await store.upsert(document, localEditedAt: nil) + } + return document + } catch let error as APIError { + // Covers both halves of the invalid case: a document that was + // deleted underneath us, and a destination folder that no longer + // exists — the server answers 404 for either. + if case .notFound = error { + throw DocumentsError.notFound + } + throw error + } + } + + // MARK: - Sidebar tree + + public func documentTree() async throws -> DocumentTreeSnapshot { + let response = try await api.send(Documents.tree()) + let snapshot = DocumentTreeSnapshot(from: response) + // Write the folders through so `cachedFolders()` keeps painting the + // sidebar before the network returns. Only folders: the tree's + // document rows are summaries without a body or `updatedAt`, and + // upserting those into the document cache would overwrite real cached + // documents with emptier ones — the precise regression the summary + // type exists to prevent. + if let store { + for folder in snapshot.folders { + await store.upsertFolder(folder) + } + } + return snapshot + } + + // MARK: - Public documents + + public func publicDocuments(ofUser username: String) async throws -> PublicUserDocuments { + let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + // Invalid input: an empty handle would resolve to + // `/api/users//documents`, a different route entirely. Refuse + // before the request rather than asking the server about it. + throw DocumentsError.notFound + } + do { + let response = try await api.send(Documents.publicDocuments(username: trimmed)) + return PublicUserDocuments(username: trimmed, from: response) + } catch let error as APIError { + if case .notFound = error { + throw DocumentsError.notFound + } + throw error + } + } + + // MARK: - Invites + + public func invite(token: String) async throws -> DocumentInvite { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DocumentsError.notFound + } + do { + let dto = try await api.send(Documents.invite(token: trimmed)) + return DocumentInvite(token: trimmed, from: dto) + } catch let error as APIError { + // The server deliberately collapses unknown / expired / revoked / + // deleted-document into one 404 so tokens can't be probed. Keep + // that collapse — do not try to distinguish them in the UI. + if case .notFound = error { + throw DocumentsError.notFound + } + throw error + } + } + public func delete(id: String) async throws { do { try await api.sendVoid(Documents.delete(id: id)) @@ -423,6 +652,17 @@ public final class DocumentsService: DocumentsServicing { sync?.events } + // MARK: - Entitlement gate + + /// Throws `DocumentsError.subscriberRequired` when the live account is not + /// a subscriber. The single place the documents surface consults + /// entitlements, so the #40 follow-up is a one-line change here. + private func requireSubscriber() throws { + guard entitlementsProvider().isSubscriber else { + throw DocumentsError.subscriberRequired + } + } + // MARK: - Multipart helpers private func makeMultipartBody( diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift new file mode 100644 index 0000000..4966758 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift @@ -0,0 +1,430 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for the work-consolidation.md **G24** additions to +/// `DocumentsService`: the single-call sidebar tree, public documents by user, +/// create-directly-in-a-folder (subscriber-gated), move-between-folders, and +/// the invite landing. Quartet per method. +final class DocumentsTreeServiceTests: XCTestCase { + + // MARK: - Fixtures + + /// The live 2026-09-09 tree body, ids shortened. Keeps the two structural + /// facts under test: folders are flat with `parentId`, documents nest + /// inside them, and `rootDocuments` is a sibling array. + private static let liveTree = #""" + {"folders":[{"id":"f-one","name":"One-Folder","parentId":null, + "documents":[{"id":"d-nested","title":"a-single-doc", + "relativePath":"a-single-doc.md","isPublic":false}]}, + {"id":"f-templates","name":"_templates","parentId":null, + "documents":[{"id":"d-tpl","title":"Recipe","relativePath":"recipe.md"}]}, + {"id":"f-empty","name":"emptiness","parentId":null,"documents":[]}], + "rootDocuments":[{"id":"d-root","title":"a-root-doc", + "relativePath":"a-root-doc.md","isPublic":false}]} + """# + + private func subscriberService(api: APIClientProtocol) -> DocumentsService { + DocumentsService( + api: api, + entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) } + ) + } + + // MARK: - documentTree — happy path + + func test_givenLiveTree_whenFetchingTree_thenIndexesDocumentsByFolderAndKeepsRootSeparate() async throws { + let api = StubAPIClient() + await api.enqueue(json: Self.liveTree) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/tree") + XCTAssertEqual(recorded.first?.method, "GET") + + XCTAssertEqual(tree.documents(in: "f-one").map(\.id), ["d-nested"]) + XCTAssertEqual(tree.documents(in: nil).map(\.id), ["d-root"]) + // Root documents are never folded into a folder. + XCTAssertNil(tree.documentsByFolder[""]) + XCTAssertEqual(tree.documentCount(in: "f-one"), 1) + XCTAssertEqual(tree.documentCount(in: "f-empty"), 0) + } + + func test_givenTreeWithoutFolderIdOnNestedRows_whenFetchingTree_thenStampsTheEnclosingFolder() async throws { + // The wire omits `folderId` on nested rows — the folder *is* the id. + // Losing that would break "which folder is this document in?", which + // the move action depends on. + let api = StubAPIClient() + await api.enqueue(json: Self.liveTree) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + XCTAssertEqual(tree.documents(in: "f-one").first?.folderId, "f-one") + XCTAssertNil(tree.documents(in: nil).first?.folderId) + XCTAssertEqual(tree.folderID(ofDocument: "d-nested"), "f-one") + XCTAssertNil(tree.folderID(ofDocument: "d-root")) + } + + func test_givenTreeContainingTemplates_whenFetchingTree_thenTemplatesIsFoundButNotOfferedAsADestination() async throws { + let api = StubAPIClient() + await api.enqueue(json: Self.liveTree) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + // Present in the raw folder list, and findable by the template picker… + XCTAssertEqual(tree.templatesFolderID, "f-templates") + XCTAssertTrue(tree.folders.contains { $0.name == "_templates" }) + // …but never a browsable folder or a move destination. + XCTAssertFalse(tree.userFolders.contains { $0.name == "_templates" }) + XCTAssertFalse(tree.moveDestinations.contains { $0.name == "_templates" }) + XCTAssertFalse(tree.folderTree.folders.contains { $0.name == "_templates" }) + } + + func test_givenNestedFolders_whenFetchingTree_thenFolderTreeProjectsTheHierarchy() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"folders":[{"id":"f1","name":"Parent","parentId":null,"documents":[]}, + {"id":"f2","name":"Child","parentId":"f1","documents":[]}], + "rootDocuments":[]} + """#) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + XCTAssertEqual(tree.folderTree.roots.map(\.id), ["f1"]) + XCTAssertEqual(tree.folderTree.children(of: "f1").map(\.id), ["f2"]) + } + + // MARK: - documentTree — boundary + + func test_givenEmptyAccount_whenFetchingTree_thenReturnsAnEmptySnapshot() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"folders":[],"rootDocuments":[]}"#) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + XCTAssertTrue(tree.folders.isEmpty) + XCTAssertTrue(tree.rootDocuments.isEmpty) + XCTAssertEqual(tree.documentCount(in: nil), 0) + XCTAssertNil(tree.templatesFolderID) + } + + func test_givenFolderOfOnlySubfolders_whenFetchingTree_thenParentCountsZeroNotItsDescendants() async throws { + // Direct children only. A parent that rolled up its sub-folders would + // read as "3 documents" for a folder you can open and find empty. + let api = StubAPIClient() + await api.enqueue(json: #""" + {"folders":[{"id":"f-branch","name":"Branch","parentId":null,"documents":[]}, + {"id":"f-leaf","name":"Leaf","parentId":"f-branch", + "documents":[{"id":"d1","title":"One"},{"id":"d2","title":"Two"}]}], + "rootDocuments":[]} + """#) + let service = DocumentsService(api: api) + + let tree = try await service.documentTree() + + XCTAssertEqual(tree.documentCount(in: "f-branch"), 0) + XCTAssertEqual(tree.documentCount(in: "f-leaf"), 2) + } + + // MARK: - documentTree — upstream failure + + func test_givenTreeAPIFailure_whenFetchingTree_thenThrowsAPIError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .unauthorized(serverMessage: "sign in")) + let service = DocumentsService(api: api) + + do { + _ = try await service.documentTree() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .unauthorized(serverMessage: "sign in")) + } + } + + // MARK: - publicDocuments — quartet + + func test_givenPublicDocuments_whenFetchingForAUser_thenMapsRichRowsAndEchoesTheHandle() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"documents":[{"id":"d1","title":"Railroad Apps","folderId":null, + "relativePath":"railroad.md", + "createdAt":"2026-03-01T22:07:59.037Z", + "updatedAt":"2026-03-01T22:08:34.894Z"}], + "folders":[]} + """#) + let service = DocumentsService(api: api) + + let result = try await service.publicDocuments(ofUser: "adron") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/users/adron/documents") + XCTAssertEqual(result.username, "adron") + XCTAssertEqual(result.documents.map(\.id), ["d1"]) + // Unlike the tree's summaries these carry a real timestamp, not the + // `.distantPast` floor. + XCTAssertNotEqual(result.documents.first?.updatedAt, .distantPast) + } + + func test_givenBlankUsername_whenFetchingPublicDocuments_thenRefusesBeforeAnyCall() async throws { + // Invalid input: `/api/users//documents` is a different route. Assert + // no request was made at all. + let api = StubAPIClient() + let service = DocumentsService(api: api) + + do { + _ = try await service.publicDocuments(ofUser: " ") + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "no HTTP call should be made for a blank handle") + } + + func test_givenUnknownUser_whenFetchingPublicDocuments_thenMapsNotFoundToDomainError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "User not found")) + let service = DocumentsService(api: api) + + do { + _ = try await service.publicDocuments(ofUser: "nobody") + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + } + + func test_givenUserWithNothingPublic_whenFetchingPublicDocuments_thenReportsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"documents":[],"folders":[]}"#) + let service = DocumentsService(api: api) + + let result = try await service.publicDocuments(ofUser: "ghost") + + XCTAssertTrue(result.isEmpty) + } + + // MARK: - createDocument(inFolder:) — quartet + + func test_givenSubscriber_whenCreatingInAFolder_thenPostsToTheFolderRoute() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"message":"Document created successfully", + "document":{"id":"d1","title":"Notes","content":"# Notes","folderId":"f1"}} + """#) + let service = subscriberService(api: api) + + let document = try await service.createDocument( + inFolder: "f1", + title: "Notes", + body: "# Notes", + isPublic: false, + relativePath: nil + ) + + let recorded = await api.recorded + // The folder route, not `POST /api/documents` — which ignores folderId + // and would have filed this at root. + XCTAssertEqual(recorded.first?.path, "/api/documents/folders/f1/documents") + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(document.folderId, "f1") + } + + func test_givenFreeAccount_whenCreatingInAFolder_thenRefusesBeforeAnyCall() async throws { + // The gate is local so a free user gets a clear message instead of a + // bare 403 — and so the app doesn't spend a round-trip to be told no. + let api = StubAPIClient() + let service = DocumentsService( + api: api, + entitlementsProvider: { EntitlementsService(customerStatus: .free) } + ) + + do { + _ = try await service.createDocument( + inFolder: "f1", title: "Notes", body: "", isPublic: false, relativePath: nil + ) + XCTFail("Expected DocumentsError.subscriberRequired") + } catch let error as DocumentsError { + XCTAssertEqual(error, .subscriberRequired) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "the gate must run before the HTTP call") + } + + func test_givenServerSideSubscriptionRejection_whenCreatingInAFolder_thenMapsForbiddenToSubscriberRequired() async throws { + // The server gates this route too. Both gates must produce the same + // typed error so the UI branches once. + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "Subscribe to create documents.")) + let service = subscriberService(api: api) + + do { + _ = try await service.createDocument( + inFolder: "f1", title: "Notes", body: "", isPublic: false, relativePath: nil + ) + XCTFail("Expected DocumentsError.subscriberRequired") + } catch let error as DocumentsError { + XCTAssertEqual(error, .subscriberRequired) + } + } + + func test_givenVanishedFolder_whenCreatingInIt_thenMapsNotFoundToDomainError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Folder not found")) + let service = subscriberService(api: api) + + do { + _ = try await service.createDocument( + inFolder: "gone", title: "Notes", body: "", isPublic: false, relativePath: nil + ) + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + } + + func test_givenEmptyBody_whenCreatingInAFolder_thenStillSucceeds() async throws { + // Boundary: "New Document" creates an empty buffer titled Untitled. + let api = StubAPIClient() + await api.enqueue(json: #"{"document":{"id":"d1","title":"Untitled","folderId":"f1"}}"#) + let service = subscriberService(api: api) + + let document = try await service.createDocument( + inFolder: "f1", title: "Untitled", body: "", isPublic: false, relativePath: nil + ) + + XCTAssertEqual(document.body.markdown, "") + XCTAssertEqual(document.title, "Untitled") + } + + // MARK: - moveDocument — quartet + + func test_givenDestinationFolder_whenMovingADocument_thenPatchesTheDocumentRoute() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"document":{"id":"d1","title":"Doc","folderId":"f2"}}"#) + let service = DocumentsService(api: api) + + let moved = try await service.moveDocument(id: "d1", toFolder: "f2") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/d1") + XCTAssertEqual(recorded.first?.method, "PATCH") + XCTAssertEqual(moved.folderId, "f2") + } + + func test_givenRootDestination_whenMovingADocument_thenReturnsAnUnfiledDocument() async throws { + // Boundary: "No folder (root)". The explicit-null body is asserted at + // the kit layer; here the domain contract is that the result is unfiled. + let api = StubAPIClient() + await api.enqueue(json: #"{"document":{"id":"d1","title":"Doc","folderId":null}}"#) + let service = DocumentsService(api: api) + + let moved = try await service.moveDocument(id: "d1", toFolder: nil) + + XCTAssertNil(moved.folderId) + } + + func test_givenFolderThatNoLongerExists_whenMovingADocument_thenMapsNotFoundToDomainError() async throws { + // Invalid input as the user experiences it: the sidebar offered a + // folder that was deleted in another window a moment ago. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Folder not found")) + let service = DocumentsService(api: api) + + do { + _ = try await service.moveDocument(id: "d1", toFolder: "deleted-folder") + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + } + + func test_givenMoveAPIFailure_whenMovingADocument_thenBubblesTheAPIError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = DocumentsService(api: api) + + do { + _ = try await service.moveDocument(id: "d1", toFolder: "f2") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - invite — quartet + + func test_givenResolvableInvite_whenResolving_thenMapsEveryBranchFlagAndThreadsTheToken() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"role":"collaborator","needsAuth":false,"canClaim":true, + "wrongAccount":false,"accepted":false,"resourceTitle":"Q3 Planning"} + """#) + let service = DocumentsService(api: api) + + let invite = try await service.invite(token: "xN3v9Qk") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/invite/xN3v9Qk") + XCTAssertEqual(invite.role, "collaborator") + XCTAssertEqual(invite.resourceTitle, "Q3 Planning") + XCTAssertTrue(invite.canClaim) + // The token is not in the response body; it must survive from the + // request so the landing can build the browser hand-off. + XCTAssertEqual(invite.token, "xN3v9Qk") + XCTAssertEqual( + invite.acceptURL(base: URL(string: "https://interlinedlist.com")!).absoluteString, + "https://interlinedlist.com/documents/invite/xN3v9Qk" + ) + } + + func test_givenBlankToken_whenResolvingAnInvite_thenRefusesBeforeAnyCall() async throws { + let api = StubAPIClient() + let service = DocumentsService(api: api) + + do { + _ = try await service.invite(token: " ") + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenUnknownOrExpiredToken_whenResolvingAnInvite_thenMapsNotFoundToDomainError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Invite not found, expired, or revoked")) + let service = DocumentsService(api: api) + + do { + _ = try await service.invite(token: "faketoken") + XCTFail("Expected DocumentsError.notFound") + } catch let error as DocumentsError { + XCTAssertEqual(error, .notFound) + } + } + + func test_givenInviteWithOnlyARole_whenResolving_thenAbsentFlagsBecomeFalse() async throws { + // Boundary: the success body is unverified live, so the mapper must + // cope with everything but `role` missing. + let api = StubAPIClient() + await api.enqueue(json: #"{"role":"watcher"}"#) + let service = DocumentsService(api: api) + + let invite = try await service.invite(token: "tok") + + XCTAssertEqual(invite.role, "watcher") + XCTAssertFalse(invite.needsAuth) + XCTAssertFalse(invite.canClaim) + XCTAssertFalse(invite.wrongAccount) + XCTAssertFalse(invite.accepted) + XCTAssertNil(invite.resourceTitle) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift index efbcf6c..1388b95 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentDTO.swift @@ -273,3 +273,251 @@ public struct UpdateDocumentFolderRequest: Codable, Sendable, Equatable { self.parentId = parentId } } + +// MARK: - Sidebar tree (work-consolidation.md G24) + +/// One folder row inside `GET /api/documents/tree`. +/// +/// VERIFIED live 2026-09-09 (read-only Bearer probe). The tree is **flat in +/// its folders and nested in its documents**: every folder in the account +/// appears once at the top level of `folders`, nesting is expressed by +/// `parentId` (exactly like `DocumentFolderDTO`), and each folder carries its +/// own documents inline under `documents`. Do not look for a nested `folders` +/// array — there isn't one. +/// +/// The inline document rows are **lighter than `GET /api/documents` rows**: +/// only `id`, `title`, `relativePath` and `isPublic` are present. There is no +/// `content`, no `createdAt` / `updatedAt`, and no `folderId` (the enclosing +/// folder *is* the folder id). That is why the tree replaces the sidebar's +/// folder fetch but cannot replace the document list's own fetch — see +/// `DocumentTreeResponse`. +public struct DocumentTreeFolderDTO: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let parentId: String? + /// Documents filed directly in this folder. Defaulted to `[]` rather than + /// required: an empty folder answers `"documents":[]` today, but a future + /// slimmer variant that omits the key entirely must not fail the decode. + public let documents: [DocumentDTO] + + public init( + id: String, + name: String, + parentId: String? = nil, + documents: [DocumentDTO] = [] + ) { + self.id = id + self.name = name + self.parentId = parentId + self.documents = documents + } + + private enum CodingKeys: String, CodingKey { + case id, name, parentId, documents + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(String.self, forKey: .id) + self.name = try c.decode(String.self, forKey: .name) + self.parentId = try c.decodeIfPresent(String.self, forKey: .parentId) + self.documents = try c.decodeIfPresent([DocumentDTO].self, forKey: .documents) ?? [] + } +} + +/// `GET /api/documents/tree` — the whole sidebar in one call. +/// +/// VERIFIED live 2026-09-09 (read-only Bearer probe against the `.env` test +/// account). `OPTIONS` reports `Allow: GET, HEAD, OPTIONS`; the live body is: +/// +/// ```json +/// {"folders":[{"id":"2b77…","name":"One-Folder","parentId":null, +/// "documents":[{"id":"effb…","title":"a-single-doc", +/// "relativePath":"a-single-doc.md","isPublic":false}]}, +/// {"id":"3775…","name":"emptiness","parentId":null,"documents":[]}], +/// "rootDocuments":[{"id":"6066…","title":"a-root-doc", +/// "relativePath":"a-root-doc.md","isPublic":false}]} +/// ``` +/// +/// Two things the shape forces on consumers: +/// +/// 1. **Root documents are a sibling array, not a synthetic folder.** Do not +/// flatten `rootDocuments` into `folders` — "no folder" is a distinct +/// destination the UI has to be able to name. +/// 2. **The `_templates` folder is returned inline** alongside real folders +/// (it is the same folder the template picker seeds). Anything offering +/// folders as a user-facing destination has to filter it out. +public struct DocumentTreeResponse: Codable, Sendable, Equatable { + public let folders: [DocumentTreeFolderDTO] + /// Documents with no folder. A sibling of `folders`, never nested in it. + public let rootDocuments: [DocumentDTO] + + public init( + folders: [DocumentTreeFolderDTO] = [], + rootDocuments: [DocumentDTO] = [] + ) { + self.folders = folders + self.rootDocuments = rootDocuments + } + + private enum CodingKeys: String, CodingKey { + case folders, rootDocuments + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + // Both keys defaulted: an account with no folders at all still has to + // decode, and the sidebar treats "absent" and "empty" identically. + self.folders = try c.decodeIfPresent([DocumentTreeFolderDTO].self, forKey: .folders) ?? [] + self.rootDocuments = try c.decodeIfPresent([DocumentDTO].self, forKey: .rootDocuments) ?? [] + } +} + +// MARK: - Public documents by user + +/// `GET /api/users/{username}/documents` — a user's *public* documents. +/// +/// VERIFIED live 2026-09-09: `x-auth-type: none` in the live OpenAPI spec, and +/// the route answered identically with and without an `Authorization` header, +/// so the builder sends `.none`. The live body: +/// +/// ```json +/// {"documents":[{"id":"0260…","title":"Railroad Apps to Build for Fun", +/// "folderId":null,"relativePath":"railroad-passenger-seating.md", +/// "createdAt":"…","updatedAt":"…"}], +/// "folders":[]} +/// ``` +/// +/// Unlike the tree's inline rows these carry `folderId`, `createdAt` and +/// `updatedAt` (but still no `content` — fetch the document to read it). +/// +/// `folders` was empty on every account reachable read-only, so its non-empty +/// shape is **unverified**. It is typed as `DocumentTreeFolderDTO`, whose +/// `documents` array is optional-with-default, so it decodes whether the +/// server sends plain folder rows or folder rows with documents nested. +public struct PublicUserDocumentsResponse: Codable, Sendable, Equatable { + public let documents: [DocumentDTO] + public let folders: [DocumentTreeFolderDTO] + + public init( + documents: [DocumentDTO] = [], + folders: [DocumentTreeFolderDTO] = [] + ) { + self.documents = documents + self.folders = folders + } + + private enum CodingKeys: String, CodingKey { + case documents, folders + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.documents = try c.decodeIfPresent([DocumentDTO].self, forKey: .documents) ?? [] + self.folders = try c.decodeIfPresent([DocumentTreeFolderDTO].self, forKey: .folders) ?? [] + } +} + +// MARK: - Invite landing + +/// `GET /api/documents/invite/{token}` — the invite landing payload. +/// +/// Shape per the live `/help/api/sharing` reference; the 404 branch was +/// verified live 2026-09-09 (`{"error":"Invite not found, expired, or +/// revoked","code":"not_found"}` for an unknown token, unauthenticated). +/// The success branch could not be exercised read-only — minting an invite is +/// a write, and the shared test account is off-limits for writes — so every +/// field except `role` is optional and the decode is tolerant. +/// +/// The response deliberately never returns the invited email address: it +/// reveals only enough to pick the right branch of the landing page. +/// +/// Note the pairing: `GET` here is public, but `POST /api/documents/invite/ +/// {token}` (the *accept*) is `x-auth-type: session` — a Bearer-only client +/// cannot claim. See `Documents.invite(token:)`. +public struct DocumentInviteLandingDTO: Codable, Sendable, Equatable { + /// The role the invite grants (`watcher` / `collaborator` / `manager`). + public let role: String + /// `true` when nobody is signed in → prompt to sign in or create an account. + public let needsAuth: Bool? + /// `true` when the signed-in user's verified email matches the invited + /// address, so they may `POST` to claim. + public let canClaim: Bool? + /// `true` when someone *is* signed in but under a different address. + public let wrongAccount: Bool? + /// `true` when the invite was already claimed. It still resolves, so the + /// landing page can link the claimer into the document. + public let accepted: Bool? + /// The document title, for display. + public let resourceTitle: String? + + public init( + role: String, + 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: - Request bodies (G24) + +/// `POST /api/documents/folders/{id}/documents` body. +/// +/// Deliberately **has no `folderId`** — the folder is the path. This is the +/// only way to create a document inside a folder: `POST /api/documents` +/// documents itself as "always creates at root: there is no `folderId` in its +/// body", so a `folderId` passed there is silently dropped. +public struct CreateDocumentInFolderRequest: Codable, Sendable, Equatable { + public let title: String + public let content: String + public let relativePath: String? + public let isPublic: Bool? + + public init( + title: String, + content: String, + relativePath: String? = nil, + isPublic: Bool? = nil + ) { + self.title = title + self.content = content + self.relativePath = relativePath + self.isPublic = isPublic + } +} + +/// `PATCH /api/documents/{id}` body for a **move**, and only a move. +/// +/// It exists because `UpdateDocumentRequest` cannot express "move to root". +/// Codable's synthesised encoding uses `encodeIfPresent` for optionals, so a +/// `nil` `folderId` is *omitted*, and an omitted key is "leave it alone" — +/// the document would never leave its folder. This type always writes the +/// key, emitting an explicit `null` for root. +public struct MoveDocumentRequest: Encodable, Sendable, Equatable { + /// Destination folder, or `nil` for "no folder (root)". + public let folderId: String? + + public init(folderId: String?) { + self.folderId = folderId + } + + private enum CodingKeys: String, CodingKey { + case folderId + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + // `encode`, never `encodeIfPresent`: the explicit `null` is the wire + // signal for "move out of every folder". + try c.encode(folderId, forKey: .folderId) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift index 1a887d9..de7010b 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentsEndpoint.swift @@ -163,6 +163,112 @@ public enum Documents { Request(method: .delete, path: "/api/documents/folders/\(id)", auth: .bearer) } + // MARK: - Sidebar tree (work-consolidation.md G24) + + /// `GET /api/documents/tree` — the entire sidebar in a single call: + /// every folder (flat, nested via `parentId`) with its documents inline, + /// plus the unfiled documents as a sibling `rootDocuments` array. + /// + /// VERIFIED live 2026-09-09 (read-only Bearer probe, `.env` test account): + /// HTTP 200, and `OPTIONS` reports `Allow: GET, HEAD, OPTIONS`. The live + /// OpenAPI spec marks it `x-auth-type: sync-token`, `x-subscription-tier: + /// free`. See `DocumentTreeResponse` for the body and its two consumer + /// constraints (root documents are a sibling array; `_templates` comes + /// back inline). + /// + /// This replaces the sidebar's `folders(limit:offset:)` fetch. It does + /// **not** replace `folderDocuments(id:)` / `list()`: the tree's inline + /// document rows carry no `content` and no `updatedAt`, so a list column + /// that orders by recency or an editor that opens a body still needs the + /// heavier per-folder read. + public static func tree() -> Request { + Request(method: .get, path: "/api/documents/tree", auth: .bearer) + } + + // MARK: - Public documents by user (work-consolidation.md G24) + + /// `GET /api/users/[username]/documents` — a user's public documents. + /// + /// VERIFIED live 2026-09-09: `auth: .none` is deliberate and checked — + /// the spec reports `x-auth-type: none` and the route returned the same + /// body with and without an `Authorization` header. Sending Bearer here + /// would work but would misreport the endpoint's contract. + public static func publicDocuments(username: String) -> Request { + Request( + method: .get, + path: "/api/users/\(username)/documents", + auth: .none + ) + } + + // MARK: - Create inside a folder (work-consolidation.md G24) + + /// `POST /api/documents/folders/[id]/documents` — create a document + /// **directly in a folder**. + /// + /// VERIFIED live 2026-09-09 via `OPTIONS`, which reports + /// `Allow: GET, HEAD, OPTIONS, POST` (no write was performed — the test + /// account is shared). The spec marks it `x-subscription-tier: subscriber` + /// and answers `201`. + /// + /// This is the *only* route that files a new document into a folder. The + /// live reference is explicit that `POST /api/documents` "always creates + /// at root: there is no `folderId` in its body", so passing a folder there + /// is silently ignored. `DocumentsService.createDocument(inFolder:…)` + /// routes here whenever a folder is selected. + public static func createInFolder( + folderId: String, + _ body: CreateDocumentInFolderRequest + ) -> Request { + Request( + method: .post, + path: "/api/documents/folders/\(folderId)/documents", + body: .json(body), + auth: .bearer + ) + } + + // MARK: - Move between folders (work-consolidation.md G24) + + /// `PATCH /api/documents/[id]` carrying only `folderId` — move a document + /// into a folder, or out to root when `folderId` is `nil`. + /// + /// Split out from `update(id:_:)` because only `MoveDocumentRequest` can + /// encode the explicit `null` that means "no folder (root)"; the general + /// `UpdateDocumentRequest` omits nil keys, which the server reads as + /// "leave the folder alone". The live reference states `PUT` and `PATCH` + /// "accept `folderId` to move a document into (or out of) a folder". + public static func move(id: String, toFolderId folderId: String?) -> Request { + Request( + method: .patch, + path: "/api/documents/\(id)", + body: .json(MoveDocumentRequest(folderId: folderId)), + auth: .bearer + ) + } + + // MARK: - Invite landing (work-consolidation.md G24) + + /// `GET /api/documents/invite/[token]` — resolve an email invite for its + /// landing page. Public: `auth: .none`. + /// + /// VERIFIED live 2026-09-09 unauthenticated: an unknown token answers + /// `404 {"error":"Invite not found, expired, or revoked","code": + /// "not_found"}`, and `OPTIONS` reports `Allow: GET, HEAD, OPTIONS, POST`. + /// + /// **There is deliberately no accept builder.** `POST /api/documents/ + /// invite/[token]` is `x-auth-type: session` in the live spec — it is + /// authenticated by the browser session cookie only, so a Bearer sync-token + /// client cannot claim an invite no matter how the request is shaped. The + /// Mac renders the landing state and hands the accept step to the browser. + public static func invite(token: String) -> Request { + Request( + method: .get, + path: "/api/documents/invite/\(token)", + auth: .none + ) + } + /// `GET /api/documents/folders/[id]/documents` public static func folderDocuments( id: String, diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsTreeEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsTreeEndpointTests.swift new file mode 100644 index 0000000..92dab64 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentsTreeEndpointTests.swift @@ -0,0 +1,440 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the work-consolidation.md **G24** Documents routes: the +/// single-call sidebar tree, public documents by user, create-directly-in-a- +/// folder, the move body, and the invite landing. +/// +/// The tree fixtures are the **live 2026-09-09 body**, trimmed of ids only — +/// the contract that folders nest via `parentId` while their documents nest +/// inline, and that root documents are a sibling array, is the thing worth +/// pinning, so the tests assert it rather than a hand-simplified shape. +final class DocumentsTreeEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + // MARK: - Builder shape assertions + + func test_givenG24Builders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(Documents.tree().path, "/api/documents/tree") + XCTAssertEqual(Documents.tree().method, .get) + XCTAssertEqual(Documents.tree().auth, .bearer) + // Unpaged by design — the tree returns the whole account in one body. + XCTAssertNil(Documents.tree().paginationKey) + + // Public: verified live to answer identically with and without a + // bearer token, and `x-auth-type: none` in the live spec. + XCTAssertEqual(Documents.publicDocuments(username: "adron").path, "/api/users/adron/documents") + XCTAssertEqual(Documents.publicDocuments(username: "adron").auth, .none) + + let create = Documents.createInFolder( + folderId: "f1", + CreateDocumentInFolderRequest(title: "t", content: "c") + ) + XCTAssertEqual(create.path, "/api/documents/folders/f1/documents") + XCTAssertEqual(create.method, .post) + XCTAssertEqual(create.auth, .bearer) + XCTAssertNotNil(create.body) + + let move = Documents.move(id: "d1", toFolderId: "f1") + XCTAssertEqual(move.path, "/api/documents/d1") + XCTAssertEqual(move.method, .patch) + + // The invite *landing* is public; there is deliberately no accept + // builder — the claim route is session-cookie-only upstream. + XCTAssertEqual(Documents.invite(token: "tok").path, "/api/documents/invite/tok") + XCTAssertEqual(Documents.invite(token: "tok").method, .get) + XCTAssertEqual(Documents.invite(token: "tok").auth, .none) + } + + // MARK: - tree — happy path + + func test_givenLiveTreeBody_whenTreeSent_thenNestsDocumentsUnderFoldersAndKeepsRootAsSibling() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folders":[{"id":"f-one","name":"One-Folder","parentId":null, + "documents":[{"id":"d-nested","title":"a-single-doc", + "relativePath":"a-single-doc.md","isPublic":false}]}, + {"id":"f-templates","name":"_templates","parentId":null, + "documents":[{"id":"d-tpl","title":"Recipe", + "relativePath":"recipe.md","isPublic":false}]}, + {"id":"f-empty","name":"emptiness","parentId":null,"documents":[]}], + "rootDocuments":[{"id":"d-root","title":"a-root-doc", + "relativePath":"a-root-doc.md","isPublic":false}]} + """#)) + + let tree = try await client.send(Documents.tree()) + + XCTAssertEqual(tree.folders.map(\.id), ["f-one", "f-templates", "f-empty"]) + // Documents live *inside* their folder… + XCTAssertEqual(tree.folders[0].documents.map(\.id), ["d-nested"]) + XCTAssertEqual(tree.folders[0].documents.first?.relativePath, "a-single-doc.md") + // …and root documents stay a sibling array, never folded into folders. + XCTAssertEqual(tree.rootDocuments.map(\.id), ["d-root"]) + XCTAssertFalse(tree.folders.contains { $0.id == "d-root" }) + // The server hands back `_templates` inline with real folders. + XCTAssertTrue(tree.folders.contains { $0.name == "_templates" }) + } + + func test_givenNestedFolders_whenTreeSent_thenNestingIsCarriedByParentIdNotByNesting() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folders":[{"id":"f1","name":"Parent","parentId":null,"documents":[]}, + {"id":"f2","name":"Child","parentId":"f1","documents":[]}], + "rootDocuments":[]} + """#)) + + let tree = try await client.send(Documents.tree()) + + // Both folders are top-level entries in `folders`; the relationship is + // in `parentId`. A consumer that looked for a nested `folders` array + // would see a flat two-item list and think the tree had no hierarchy. + XCTAssertEqual(tree.folders.count, 2) + XCTAssertNil(tree.folders[0].parentId) + XCTAssertEqual(tree.folders[1].parentId, "f1") + } + + // MARK: - tree — boundary + + func test_givenEmptyFolderAndFolderOfOnlySubfolders_whenTreeSent_thenBothDecodeWithNoDocuments() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folders":[{"id":"f-empty","name":"emptiness","parentId":null,"documents":[]}, + {"id":"f-branch","name":"Branch","parentId":null,"documents":[]}, + {"id":"f-leaf","name":"Leaf","parentId":"f-branch","documents":[]}], + "rootDocuments":[]} + """#)) + + let tree = try await client.send(Documents.tree()) + + XCTAssertTrue(tree.folders.allSatisfy { $0.documents.isEmpty }) + XCTAssertTrue(tree.rootDocuments.isEmpty) + } + + func test_givenTreeOmittingBothArrays_whenTreeSent_thenDecodesToEmptyRatherThanFailing() async throws { + // Boundary: a brand-new account, and defensive cover for a slimmer + // future variant that drops empty keys entirely. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{}"#)) + + let tree = try await client.send(Documents.tree()) + + XCTAssertTrue(tree.folders.isEmpty) + XCTAssertTrue(tree.rootDocuments.isEmpty) + } + + func test_givenFolderWithoutDocumentsKey_whenTreeSent_thenDefaultsToEmptyArray() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folders":[{"id":"f1","name":"NoKey","parentId":null}],"rootDocuments":[]} + """#)) + + let tree = try await client.send(Documents.tree()) + + XCTAssertEqual(tree.folders.first?.documents, []) + } + + // MARK: - tree — upstream failure + + func test_givenUnauthorized_whenTreeSent_thenSurfacesAnAuthFailure() async throws { + // A 401 on a bearer request trips the client's session-retry safety + // net, and the empty session queue then fails at the transport. Both + // outcomes are correct surfaced errors for this path — the assertion + // is that the failure reaches the caller, not which of the two it is + // (same convention as `MessagesEndpointTests`' 401 case). + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"sign in"}"#, status: 401)) + + do { + _ = try await client.send(Documents.tree()) + XCTFail("Expected an APIError") + } catch let error as APIError { + switch error { + case .unauthorized, .transport: + break + default: + XCTFail("Expected .unauthorized or .transport, got \(error)") + } + } + } + + // MARK: - tree — invalid body + + func test_givenFolderMissingItsName_whenTreeSent_thenThrowsDecodingError() async throws { + // Invalid input from upstream: `id`/`name` are the two fields the + // sidebar cannot render without, so they stay required. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"folders":[{"id":"f1"}],"rootDocuments":[]}"#)) + + do { + _ = try await client.send(Documents.tree()) + XCTFail("Expected a decoding failure") + } catch let error as APIError { + guard case .decoding = error else { + return XCTFail("Expected .decoding, got \(error)") + } + } + } + + // MARK: - publicDocuments + + func test_givenLivePublicDocumentsBody_whenSent_thenDecodesRicherRowsThanTheTree() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"documents":[{"id":"d1","title":"Railroad Apps to Build for Fun","folderId":null, + "relativePath":"railroad-passenger-seating.md", + "createdAt":"2026-03-01T22:07:59.037Z", + "updatedAt":"2026-03-01T22:08:34.894Z"}], + "folders":[]} + """#)) + + let result = try await client.send(Documents.publicDocuments(username: "adron")) + + XCTAssertEqual(result.documents.map(\.id), ["d1"]) + // These rows carry timestamps the tree's inline rows do not — the + // reason the profile column can sort and date them. + XCTAssertNotNil(result.documents.first?.updatedAt) + XCTAssertNotNil(result.documents.first?.createdAt) + XCTAssertTrue(result.folders.isEmpty) + } + + func test_givenUserWithNothingPublic_whenPublicDocumentsSent_thenDecodesEmptyArrays() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"documents":[],"folders":[]}"#)) + + let result = try await client.send(Documents.publicDocuments(username: "ghost")) + + XCTAssertTrue(result.documents.isEmpty) + XCTAssertTrue(result.folders.isEmpty) + } + + func test_givenUnknownUser_whenPublicDocumentsSent_thenThrowsNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"User not found"}"#, status: 404)) + + do { + _ = try await client.send(Documents.publicDocuments(username: "nobody")) + XCTFail("Expected an APIError") + } catch let error as APIError { + guard case .notFound = error else { + return XCTFail("Expected .notFound, got \(error)") + } + } + } + + func test_givenPublicFoldersCarryingNestedDocuments_whenSent_thenStillDecodes() async throws { + // The live probe only ever saw `"folders":[]`, so the non-empty shape + // is unverified. Prove the tolerant type survives either possibility + // rather than discovering it in production. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"documents":[], + "folders":[{"id":"f1","name":"Public","parentId":null, + "documents":[{"id":"d1","title":"Inside"}]}]} + """#)) + + let result = try await client.send(Documents.publicDocuments(username: "adron")) + + XCTAssertEqual(result.folders.first?.documents.map(\.id), ["d1"]) + } + + // MARK: - createInFolder + + func test_givenTitleAndBody_whenCreateInFolderSent_thenPostsToFolderRouteWithNoFolderIdInBody() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"message":"Document created successfully", + "document":{"id":"d1","title":"Notes","content":"# Notes","folderId":"f1"}} + """#, status: 201)) + + let response = try await client.send( + Documents.createInFolder( + folderId: "f1", + CreateDocumentInFolderRequest(title: "Notes", content: "# Notes") + ) + ) + + XCTAssertEqual(response.document.id, "d1") + XCTAssertEqual(response.document.folderId, "f1") + + let received = await transport.received + XCTAssertEqual(received.last?.url?.path, "/api/documents/folders/f1/documents") + XCTAssertEqual(received.last?.httpMethod, "POST") + let body = try XCTUnwrap(received.last?.httpBody) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(json["title"] as? String, "Notes") + // The folder is the path, never the body — mirroring the route's own + // contract. A `folderId` here would be meaningless at best. + XCTAssertNil(json["folderId"]) + } + + func test_givenFreeAccount_whenCreateInFolderSent_thenThrowsForbidden() async throws { + // The route is `x-subscription-tier: subscriber`; the client must + // surface the 403 rather than swallowing it. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Subscribe to create documents."}"#, status: 403)) + + do { + _ = try await client.send( + Documents.createInFolder( + folderId: "f1", + CreateDocumentInFolderRequest(title: "t", content: "") + ) + ) + XCTFail("Expected an APIError") + } catch let error as APIError { + guard case .forbidden = error else { + return XCTFail("Expected .forbidden, got \(error)") + } + } + } + + func test_givenEmptyBody_whenCreateInFolderSent_thenStillEncodesBothRequiredKeys() async throws { + // Boundary: an empty document is legitimate — the editor creates one + // called "Untitled" with no content at all. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"document":{"id":"d1","title":"Untitled"}}"#, status: 201)) + + _ = try await client.send( + Documents.createInFolder( + folderId: "f1", + CreateDocumentInFolderRequest(title: "Untitled", content: "") + ) + ) + + let received = await transport.received + let body = try XCTUnwrap(received.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["title"] as? String, "Untitled") + XCTAssertEqual(json["content"] as? String, "") + } + + // MARK: - move + + func test_givenDestinationFolder_whenMoveSent_thenPatchesWithThatFolderId() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"document":{"id":"d1","title":"Doc","folderId":"f2"}}"#)) + + let response = try await client.send(Documents.move(id: "d1", toFolderId: "f2")) + + XCTAssertEqual(response.document.folderId, "f2") + let received = await transport.received + XCTAssertEqual(received.last?.httpMethod, "PATCH") + let body = try XCTUnwrap(received.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["folderId"] as? String, "f2") + } + + func test_givenRootDestination_whenMoveSent_thenEncodesAnExplicitNullNotAnOmittedKey() async throws { + // The whole reason `MoveDocumentRequest` exists. Codable's synthesised + // encoding would drop a nil `folderId`, and an omitted key means "leave + // the folder alone" — the document would never reach root. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"document":{"id":"d1","title":"Doc","folderId":null}}"#)) + + _ = try await client.send(Documents.move(id: "d1", toFolderId: nil)) + + let received = await transport.received + let body = try XCTUnwrap(received.last?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertTrue(json.keys.contains("folderId"), "folderId must be present, not omitted") + XCTAssertTrue(json["folderId"] is NSNull, "folderId must be an explicit null") + } + + func test_givenMissingDocumentOrFolder_whenMoveSent_thenThrowsNotFound() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Document not found"}"#, status: 404)) + + do { + _ = try await client.send(Documents.move(id: "gone", toFolderId: "f2")) + XCTFail("Expected an APIError") + } catch let error as APIError { + guard case .notFound = error else { + return XCTFail("Expected .notFound, got \(error)") + } + } + } + + // MARK: - invite + + func test_givenResolvableInvite_whenInviteSent_thenDecodesEveryLandingBranchFlag() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"role":"collaborator","needsAuth":false,"canClaim":true, + "wrongAccount":false,"accepted":false,"resourceTitle":"Q3 Planning"} + """#)) + + let invite = try await client.send(Documents.invite(token: "xN3v9Qk")) + + XCTAssertEqual(invite.role, "collaborator") + XCTAssertEqual(invite.canClaim, true) + XCTAssertEqual(invite.needsAuth, false) + XCTAssertEqual(invite.wrongAccount, false) + XCTAssertEqual(invite.accepted, false) + XCTAssertEqual(invite.resourceTitle, "Q3 Planning") + } + + func test_givenInviteWithOnlyARole_whenInviteSent_thenOptionalFlagsDecodeAsNil() async throws { + // Boundary: the success body could not be exercised live (minting an + // invite is a write), so everything but `role` is optional and must + // survive being absent. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"role":"watcher"}"#)) + + let invite = try await client.send(Documents.invite(token: "tok")) + + XCTAssertEqual(invite.role, "watcher") + XCTAssertNil(invite.needsAuth) + XCTAssertNil(invite.canClaim) + XCTAssertNil(invite.resourceTitle) + } + + func test_givenUnknownOrExpiredToken_whenInviteSent_thenThrowsNotFound() async throws { + // VERIFIED live 2026-09-09, unauthenticated, against a fabricated + // token. Unknown / expired / revoked / deleted are deliberately + // indistinguishable upstream. + 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(Documents.invite(token: "faketoken")) + XCTFail("Expected an APIError") + } catch let error as APIError { + guard case .notFound = error else { + return XCTFail("Expected .notFound, got \(error)") + } + } + } + + func test_givenInviteBodyMissingRole_whenInviteSent_thenThrowsDecodingError() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"resourceTitle":"Q3 Planning"}"#)) + + do { + _ = try await client.send(Documents.invite(token: "tok")) + XCTFail("Expected a decoding failure") + } catch let error as APIError { + guard case .decoding = error else { + return XCTFail("Expected .decoding, got \(error)") + } + } + } +} diff --git a/work-consolidation.md b/work-consolidation.md index 77db669..8059fda 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -156,8 +156,19 @@ Three routes the DM feature shipped without: `GET /api/dm/conversations` (one ro `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}`. -**G24 · Documents: sidebar tree, public docs, invites, presence — MEDIUM. Size M.** -`GET /api/documents/tree` returns `{folders, rootDocuments}` in **one** call — today the sidebar assembles that from several. `GET /api/users/{username}/documents` is public documents by user (the profile page has no documents tab). `GET`/`POST /api/documents/invite/{token}` are the invite landing/claim pair matching the list ones. `POST /api/documents/folders/{id}/documents` creates a document directly in a folder. `POST`/`DELETE /api/documents/{id}/presence` is the live-cursor heartbeat — **defer**: it is a collaborative-editing feature with a polling cost, worth building only if multi-user editing is a goal. +**G24 · Documents: sidebar tree, public docs, invites, presence — SHIPPED 2026-09-09 (GitHub #52), minus the deferred presence pair.** +Built on `feat/documents-tree-g24`. `GET /api/documents/tree` now backs the sidebar in **one** call (`FolderTreeViewModel.documentTree()`), which also gives the sidebar per-folder document counts for free. `GET /api/users/{username}/documents` backs a documents column on the profile page. `POST /api/documents/folders/{id}/documents` creates a document directly in a folder, subscriber-gated. `GET /api/documents/invite/{token}` renders an invite landing that ends in "Accept in Browser". + +Four things the live probe (read-only, 2026-09-09) settled, each of which changed the plan: + +- **The tree does not retire the document-list fetch.** Its inline document rows carry only `id`, `title`, `relativePath`, `isPublic` — no body, no `updatedAt`, no `folderId`. Only the *folder* fetch retired; the middle column and the editor still need their own reads. Modelled as `DocumentSummary`, deliberately not as `Document`. +- **`POST /api/documents` silently ignored the folder.** The reference is explicit that it "always creates at root: there is no `folderId` in its body" — so every New Document created with a folder selected was landing at root. Fixed by routing through the folder route. +- **Moving to root needs an explicit `null`.** Codable omits nil optionals, and an omitted `folderId` means "leave it alone", so a dedicated `MoveDocumentRequest` always writes the key. +- **The invite accept half is genuinely out of reach.** `POST /api/documents/invite/{token}` is `x-auth-type: session`; a Bearer client cannot claim, so the landing hands off to the browser. Backend ask filed separately. + +Also closed here: **Move to folder** (editor settings menu + list context menu, with "No folder (root)", `_templates` excluded as a destination). **Seed defaults was already shipped** — `Documents.seedDefaultTemplates()` → `DocumentTemplatesService.seedDefaultTemplates()` → `ServerTemplatesViewModel.seedDefaults()`, wired in `DocumentTemplatePickerView`; that bullet closes as already-done. + +Still deferred: `POST`/`DELETE /api/documents/{id}/presence`, the live-cursor heartbeat — a collaborative-editing feature with a polling cost, worth building only if multi-user editing becomes a goal. **G25 · Organization admin + LinkedIn org pages — MEDIUM. Size M. (This is [G11b](#2d-upstream-blocked--deferred-confirm-demand-before-building), no longer upstream-blocked.)**