From 5749330ee45ea51e2639580088555fa99a38a52c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 15 Sep 2026 04:43:59 -0700 Subject: [PATCH] feat(profile): land on your own profile, and give it the web's five stat tiles and both content columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Profile put up an "enter a username" prompt even though the session already knew who the user was, so the one profile everybody wants to see was the one that took the most typing to reach. It now lands on your own account, and the lookup field stays as the way to visit someone else. Most of this was already on the wire and being thrown away. `GET /api/users/ {username}` returns publicMessageCount and publicListCount, and the kit has decoded both since the endpoint shipped — the domain model dropped them at the boundary, so the header could only ever show two of the web's five tiles. The carry-through matters in one non-obvious place: `withCounts` rebuilds the profile after the follow-counts follow-up lands, and not carrying them there would blank two tiles every time that call returned. The public-lists column is the piece the issue recorded as still needing a probe. `GET /api/users/{username}/lists` answers `{lists, pagination}` — the public browse collection, not `/api/lists/watching`, which is the caller's own watched surface and a different thing. The column mirrors PublicUserDocumentsView's self-contained shape so the profile view adds it in one line. Watch uses the route's self-subscribe branch — POST watchers with no userId — which is free, because the subscription gates granting someone else access, not following a list that is already public to you. It is modelled as its own `watch(listId:)` rather than an optional parameter on `addWatcher`: those are different intents that happen to share a URL, and addWatcher's empty-id guard has to keep erroring rather than quietly becoming a self-subscribe. Two judgement calls worth stating. A stat tile whose count is unknown is omitted rather than rendered as zero — "0 posts" and "we could not find out how many posts" look identical and mean opposite things. And ownership is compared on id, not handle, because a handle comparison goes wrong exactly where it matters: on a rename, when the session's cached handle and the profile's disagree. The `profileUnavailable` empty state is now suppressed for your own account. It means "this user has no public messages, so there is nothing to project a profile from" — a statement about other people's public content. Shown for yourself it tells a brand-new user their own profile does not exist. Tested in both directions, because suppressing it universally would hide an accurate explanation for someone else's empty profile. Handles are normalised at the single load entry point, so a deep link, a typed handle and the self-landing all agree that "adron", "@Adron" and "/user/ADRON" are the same person. Refs #44 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9 --- .../Documents/PublicUserDocumentsView.swift | 11 + App/Features/Social/ProfileRootView.swift | 54 ++++- .../Social/ProfileStatTilesView.swift | 89 ++++++++ App/Features/Social/ProfileViewModel.swift | 78 ++++++- App/Features/Social/PublicUserListsView.swift | 184 +++++++++++++++++ .../Social/PublicUserListsViewModel.swift | 129 ++++++++++++ AppTests/ProfileViewModelTests.swift | 195 ++++++++++++++++++ AppTests/PublicUserListsViewModelTests.swift | 149 +++++++++++++ AppTests/Support/StubListsService.swift | 16 +- .../Models/ProfileMappers.swift | 15 +- .../InterlinedDomain/Models/UserProfile.swift | 28 ++- .../Services/ListsService.swift | 20 ++ .../OwnedListsServiceTests.swift | 76 +++++++ .../ProfileContentCountsTests.swift | 113 ++++++++++ 14 files changed, 1148 insertions(+), 9 deletions(-) create mode 100644 App/Features/Social/ProfileStatTilesView.swift create mode 100644 App/Features/Social/PublicUserListsView.swift create mode 100644 App/Features/Social/PublicUserListsViewModel.swift create mode 100644 AppTests/ProfileViewModelTests.swift create mode 100644 AppTests/PublicUserListsViewModelTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileContentCountsTests.swift diff --git a/App/Features/Documents/PublicUserDocumentsView.swift b/App/Features/Documents/PublicUserDocumentsView.swift index 9613180..45c48bd 100644 --- a/App/Features/Documents/PublicUserDocumentsView.swift +++ b/App/Features/Documents/PublicUserDocumentsView.swift @@ -22,6 +22,12 @@ struct PublicUserDocumentsView: View { /// The handle to show documents for. A change re-triggers the load. let username: String + /// Reports the loaded document count to the host, so the profile's + /// Documents stat tile can show a number without a second request for data + /// this column already has (GitHub #44). Defaulted, so the column stays a + /// one-line drop-in for hosts that do not care. + var onCountChange: (Int) -> Void = { _ in } + @Environment(\.appEnvironment) private var environment @State private var viewModel: PublicUserDocumentsViewModel? @@ -43,6 +49,11 @@ struct PublicUserDocumentsView: View { ) viewModel = model await model.load(username: username) + // Reported after the load rather than observed, so a failed load + // leaves the tile absent instead of claiming zero. + if model.error == nil { + onCountChange(model.documentsLoaded.count) + } } } diff --git a/App/Features/Social/ProfileRootView.swift b/App/Features/Social/ProfileRootView.swift index e6f8763..f05b8b7 100644 --- a/App/Features/Social/ProfileRootView.swift +++ b/App/Features/Social/ProfileRootView.swift @@ -28,6 +28,17 @@ struct ProfileRootView: View { @State private var viewModel: ProfileViewModel? + /// The public-document count, reported upward by the documents column. + /// + /// Lives here rather than on the view model because the column already + /// fetches the documents for its own rows: asking a second time would be a + /// duplicate request whose answer could disagree with what is on screen. + @State private var documentCount: Int? + + /// Mirrors of the view model's session-derived flags, read in the body. + private var isOwnProfile: Bool { viewModel?.isOwnProfile ?? false } + private var isSignedIn: Bool { viewModel?.isSignedIn ?? false } + var body: some View { NavigationStack { Group { @@ -45,13 +56,23 @@ struct ProfileRootView: View { // building the view model in `.task` is the canonical // pattern. if viewModel == nil, let environment { - viewModel = ProfileViewModel( + let model = ProfileViewModel( social: environment.social, relationshipReader: environment.followRelationshipReader, currentUserID: { [weak environment] in environment?.currentUserStore.currentUserID + }, + currentUsername: { [weak environment] in + environment?.currentUserStore.currentUsername } ) + viewModel = model + // Land on your own profile (GitHub #44 / G32). The session + // already knows who the user is, so opening Profile to an + // "enter a username" prompt made the one profile everybody + // wants to see the one that took the most typing to reach. + // The lookup field stays, as the way to visit someone else. + await model.loadOwnProfileIfNeeded() } } } @@ -155,13 +176,40 @@ struct ProfileRootView: View { mutuals: mutuals, followButton: followButton ) + // The five stat tiles the web profile shows (GitHub #44). + // Followers / Following / Posts / Lists come straight off the + // profile payload — the client had been decoding the last two + // and dropping them at the domain boundary. Documents has no + // count on that payload, so the documents column reports its + // own, which is also why the tile and the column can never + // disagree. + ProfileStatTilesView( + profile: profile, + counts: counts, + documentCount: documentCount + ) + .padding(.horizontal, 16) + // 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) + PublicUserDocumentsView( + username: profile.username, + onCountChange: { documentCount = $0 } + ) + .padding(.horizontal, 16) + + // The public-lists column, with a Watch button per row when the + // profile is someone else's (GitHub #44 / G32). + Divider() + PublicUserListsView( + username: profile.username, + isOwnProfile: isOwnProfile, + isSignedIn: isSignedIn + ) + .padding(.horizontal, 16) } } } diff --git a/App/Features/Social/ProfileStatTilesView.swift b/App/Features/Social/ProfileStatTilesView.swift new file mode 100644 index 0000000..c47e434 --- /dev/null +++ b/App/Features/Social/ProfileStatTilesView.swift @@ -0,0 +1,89 @@ +// ProfileStatTilesView +// +// The five stat tiles the web profile shows — Followers, Following, Posts, +// Documents, Lists (GitHub #44 / G32). macOS showed followers and following in +// the header and nothing else. +// +// Four of the five come straight off `GET /api/users/{username}`, which has +// always returned them: +// +// {"followerCount":1,"followingCount":1, +// "publicMessageCount":31,"publicListCount":0} +// +// `publicMessageCount` and `publicListCount` were decoded by the kit and dropped +// at the domain boundary, so the information was already on the wire and simply +// not carried. The fifth, Documents, has no count on that payload — it is +// reported by the documents column, which fetches the documents anyway. +// +// A tile whose count is unknown is **omitted**, not rendered as zero. "0 posts" +// and "we could not find out how many posts" look identical and mean opposite +// things, and a profile that claims zero when the count call merely failed is +// worse than one that shows four tiles. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct ProfileStatTilesView: View { + + let profile: UserProfile + + /// The follow-counts follow-up, when it landed. Preferred over the + /// profile payload's own pair because it is the fresher read — the same + /// precedence the header already applies. + let counts: FollowCounts? + + /// The public-document count, from the documents column. `nil` until that + /// column has loaded, which is why the tile can appear a moment after the + /// other four. + let documentCount: Int? + + private struct Tile: Identifiable { + let id: String + let label: String + let value: Int + } + + private var tiles: [Tile] { + var result: [Tile] = [] + if let followers = counts?.followers ?? profile.followerCount { + result.append(Tile(id: "followers", label: "Followers", value: followers)) + } + if let following = counts?.following ?? profile.followingCount { + result.append(Tile(id: "following", label: "Following", value: following)) + } + if let posts = profile.publicMessageCount { + result.append(Tile(id: "posts", label: "Posts", value: posts)) + } + if let documentCount { + result.append(Tile(id: "documents", label: "Documents", value: documentCount)) + } + if let lists = profile.publicListCount { + result.append(Tile(id: "lists", label: "Lists", value: lists)) + } + return result + } + + var body: some View { + if !tiles.isEmpty { + HStack(alignment: .top, spacing: 12) { + ForEach(tiles) { tile in + VStack(spacing: 2) { + Text(tile.value.formatted()) + .font(.ilTitle(18)) + .monospacedDigit() + Text(tile.label) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ILColor.surface2, in: RoundedRectangle(cornerRadius: ILMetric.radiusMd)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(tile.value) \(tile.label)") + } + } + } + } +} diff --git a/App/Features/Social/ProfileViewModel.swift b/App/Features/Social/ProfileViewModel.swift index 4f1d848..0599b17 100644 --- a/App/Features/Social/ProfileViewModel.swift +++ b/App/Features/Social/ProfileViewModel.swift @@ -89,11 +89,13 @@ final class ProfileViewModel { init( social: SocialServicing, relationshipReader: FollowRelationshipReading, - currentUserID: @MainActor @escaping () -> String? + currentUserID: @MainActor @escaping () -> String?, + currentUsername: @MainActor @escaping () -> String? = { nil } ) { self.social = social self.relationshipReader = relationshipReader self.currentUserIDProvider = currentUserID + self.currentUsernameProvider = currentUsername } /// Reads the signed-in user's id when configuring the follow @@ -102,6 +104,41 @@ final class ProfileViewModel { /// out while the profile view is open). private let currentUserIDProvider: @MainActor () -> String? + /// Reads the signed-in user's handle, for the self-profile landing. + /// A closure for the same reason as `currentUserIDProvider`. + private let currentUsernameProvider: @MainActor () -> String? + + /// Normalises a handle the way the site's URLs do (GitHub #44). + /// + /// Handles are **case-insensitive** (`/user/adron` == `/user/Adron`) and + /// `/@username` is a documented shortcut, so a deep link or a typed handle + /// in either form has to resolve. Usernames are `[A-Za-z0-9_.-]`; anything + /// else typed at signup becomes `_` in the URL while the display name keeps + /// what was typed — so stripping a leading `@` and lowercasing is the whole + /// job, and the rest of the string is passed through untouched rather than + /// sanitised against a charset the client would only get subtly wrong. + static func normalizedHandle(_ raw: String) -> String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("@") { trimmed.removeFirst() } + return trimmed.lowercased() + } + + /// True when the loaded profile is the signed-in user's own. + /// + /// Compared on **id**, not handle: the id is what the session and the + /// profile payload agree on, and a handle comparison would go wrong exactly + /// where this matters — on a rename. + var isOwnProfile: Bool { + guard let profile, let current = currentUserIDProvider() else { return false } + return profile.id == current + } + + /// True when a session exists. The Watch affordance is offered only then. + var isSignedIn: Bool { currentUserIDProvider() != nil } + + /// The signed-in user's handle, if any. + var ownUsername: String? { currentUsernameProvider() } + /// The relationship reader handed through to the follow button. private let relationshipReader: FollowRelationshipReading @@ -116,7 +153,10 @@ final class ProfileViewModel { /// Counts failure is logged and dropped — the profile header is the /// load-bearing data and stays rendered. func loadProfile(username: String) async { - let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + // Normalised here, at the single entry point, so a deep link, a typed + // handle and the self-profile landing all agree on what "adron", + // "@Adron" and "/user/ADRON" resolve to (GitHub #44). + let trimmed = Self.normalizedHandle(username) guard !trimmed.isEmpty else { return } loadedUsername = trimmed @@ -127,6 +167,10 @@ final class ProfileViewModel { isLoading = true defer { isLoading = false } + // Whether this load is for the signed-in user, decided *before* the + // request so the failure path can use it too. + let isSelf = currentUsernameProvider().map { Self.normalizedHandle($0) == trimmed } ?? false + do { let resolved = try await social.profile(username: trimmed) profile = resolved @@ -168,11 +212,41 @@ final class ProfileViewModel { targetUserID: resolved.id, currentUserID: currentUserIDProvider() ) + } catch let socialError as SocialError { + // Decision 0002 / GitHub #44. `profileUnavailable` means "this user + // has no public messages, so there is nothing to project a profile + // from" — a statement about *other* people's public content. It must + // never be shown for your own account: a new user with nothing + // posted yet would open Profile and be told their profile does not + // exist. + // + // In practice the rich endpoint answers for the signed-in user + // anyway, so this is a guard against the fallback path, not an + // everyday branch. It is still worth holding, because the failure it + // prevents is the worst first impression the app can make. + if case .profileUnavailable = socialError, isSelf { + self.error = nil + } else { + self.error = socialError + } } catch { self.error = error } } + /// Opens the signed-in user's own profile. + /// + /// The reason this issue exists: `ProfileRootView` landed on an empty + /// *"enter a username"* prompt even though the session already knew who the + /// user was, so the one profile everybody wants to see took the most typing + /// to reach. No-op while signed out, and no-op if a profile is already + /// loaded — re-entering the tab should not yank the user off whoever they + /// were browsing. + func loadOwnProfileIfNeeded() async { + guard loadedUsername == nil, let handle = currentUsernameProvider() else { return } + await loadProfile(username: handle) + } + /// Re-runs `loadProfile` for the currently loaded username. Bound to /// the "Try again" button on the error state. No-op when no username /// is loaded yet. diff --git a/App/Features/Social/PublicUserListsView.swift b/App/Features/Social/PublicUserListsView.swift new file mode 100644 index 0000000..0d27fcd --- /dev/null +++ b/App/Features/Social/PublicUserListsView.swift @@ -0,0 +1,184 @@ +// PublicUserListsView +// +// The public-lists column on a profile (GitHub #44 / G32), mirroring +// `PublicUserDocumentsView`: a self-contained section, not a screen. It owns its +// view model, its loading state and its own empty copy, so the host profile view +// adds it in one line and never learns about `ListsServicing`. +// +// The Watch button appears only when the profile belongs to someone else and a +// session exists — watching your own list is meaningless, and offering the +// action while signed out would present a control that can only fail. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct PublicUserListsView: View { + + /// The handle to show lists for. A change re-triggers the load. + let username: String + + /// Whether this profile is the signed-in user's own. Drives whether the + /// Watch affordance is offered at all. + let isOwnProfile: Bool + + /// Whether a session exists. `false` while signed out, where Watch would be + /// a button that can only fail. + let isSignedIn: Bool + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: PublicUserListsViewModel? + + 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:)` re-runs when the browsed handle changes, + // which is exactly when a reload is wanted. + guard let environment else { return } + let model = viewModel ?? PublicUserListsViewModel(lists: environment.lists) + viewModel = model + await model.load(username: username) + } + } + + // MARK: - Sections + + private var header: some View { + HStack(spacing: 6) { + Image(systemName: "list.bullet.rectangle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text("Public lists") + .font(.ilSubtitle()) + if viewModel?.isLoading == true { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading public lists") + } + } + } + + @ViewBuilder + private func content(viewModel: PublicUserListsViewModel) -> some View { + if let error = viewModel.error { + errorState(error: error, viewModel: viewModel) + } else if !viewModel.hasLoaded { + // First load in flight — the header spinner is enough chrome. + EmptyView() + } else if viewModel.isEmpty { + Text("@\(username) hasn't published any lists.") + .font(.ilBody()) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.listsLoaded) { list in + PublicListRow( + list: list, + showsWatch: !isOwnProfile && isSignedIn, + isWatching: viewModel.isWatching(list.id), + isPending: viewModel.isWatchPending(list.id), + failure: viewModel.watchErrors[list.id], + onWatch: { Task { await viewModel.watch(listID: list.id) } } + ) + if list.id != viewModel.listsLoaded.last?.id { + Divider() + } + } + } + } + } + + private func errorState( + error: Error, + viewModel: PublicUserListsViewModel + ) -> 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.load(username: username) } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } +} + +// MARK: - PublicListRow + +private struct PublicListRow: View { + + let list: ListSummary + let showsWatch: Bool + let isWatching: Bool + let isPending: Bool + /// The message from a failed watch on *this* row, so the failure reports + /// where it happened rather than against the whole column. + let failure: String? + let onWatch: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(list.title.isEmpty ? "Untitled list" : list.title) + .font(.ilBody()) + .lineLimit(1) + if let description = list.description, !description.isEmpty { + Text(description) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let failure { + Text(failure) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + if showsWatch { + watchButton + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + + @ViewBuilder + private var watchButton: some View { + if isWatching { + // Deliberately a label, not a toggle. The route's self-subscribe + // branch has no documented un-watch counterpart, and offering an + // Unwatch that silently does nothing would be worse than not + // offering one — a watched list is managed from the Lists surface. + Label("Watching", systemImage: "checkmark") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + } else { + Button(action: onWatch) { + if isPending { + ProgressView().controlSize(.mini) + } else { + Text("Watch") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPending) + .accessibilityLabel("Watch \(list.title)") + } + } +} diff --git a/App/Features/Social/PublicUserListsViewModel.swift b/App/Features/Social/PublicUserListsViewModel.swift new file mode 100644 index 0000000..2388b3a --- /dev/null +++ b/App/Features/Social/PublicUserListsViewModel.swift @@ -0,0 +1,129 @@ +// PublicUserListsViewModel +// +// Drives the public-lists column on a profile (GitHub #44 / G32). The web +// profile shows a Public Lists column with a Watch button per list; macOS had +// no lists on a profile at all. +// +// The route is `GET /api/users/{username}/lists`, confirmed live 2026-09-15: +// +// {"lists":[…],"pagination":{"total":0,"limit":100,"offset":0,"hasMore":false}} +// +// That is the route the issue recorded as *"still needs probing"*. It is the +// public browse collection — distinct from `GET /api/lists/watching`, which is +// the caller's own *watched* surface and a different thing. +// +// Watching is deliberately **optimistic with rollback**: the button flips the +// moment it is pressed and flips back if the write fails, because a list you +// just watched staying unwatched for a round-trip reads as a broken button. +// +// Reads through `ListsServicing` only. Per decision 0003, this view model +// consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class PublicUserListsViewModel { + + private let lists: ListsServicing + + /// Page size. The live route defaults to 100 and a profile column showing + /// more than this is a scrolling problem, not a paging one. + static let pageSize = 50 + + // MARK: - Observable state + + /// The handle whose lists are loaded, `nil` before the first load. + private(set) var username: String? + + private(set) var listsLoaded: [ListSummary] = [] + + private(set) var isLoading: Bool = false + + private(set) var error: Error? + + /// True once a load has completed for the current handle, successfully or + /// not. Separates "this user publishes no lists" from "we have not asked", + /// which are indistinguishable from `listsLoaded` alone. + private(set) var hasLoaded: Bool = false + + /// Ids the caller now watches, from this session's own Watch presses. + /// + /// Not a read of server state: the public lists route says nothing about + /// whether *you* watch a list, and asking per row would be N requests for a + /// button. So this is a record of what was pressed here, and the button + /// starts neutral for every row on load. + private(set) var watchedIDs: Set = [] + + /// Ids with a watch write in flight, so the row can disable itself rather + /// than queue duplicate writes. + private(set) var pendingWatchIDs: Set = [] + + /// The error from the most recent failed watch, keyed by list id, so a + /// failure reports against the row that caused it rather than the column. + private(set) var watchErrors: [String: String] = [:] + + var isEmpty: Bool { listsLoaded.isEmpty } + + // MARK: - Init + + init(lists: ListsServicing) { + self.lists = lists + } + + // MARK: - Intents + + func load(username: String) async { + let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + // A repeat load for the same handle is a refresh, not a no-op — the + // profile view's `task(id:)` re-runs on handle changes only. + if self.username != trimmed { + listsLoaded = [] + watchedIDs = [] + watchErrors = [:] + hasLoaded = false + } + self.username = trimmed + isLoading = true + error = nil + defer { isLoading = false; hasLoaded = true } + + do { + let page = try await lists.publicLists( + username: trimmed, + limit: Self.pageSize, + offset: 0 + ) + listsLoaded = page.lists + } catch { + self.error = error + listsLoaded = [] + } + } + + /// Subscribes the signed-in user to `list`. + /// + /// Optimistic: the row flips immediately and rolls back on failure. The + /// rollback matters more than the optimism — a button that claims success + /// and silently did nothing is worse than a slow one. + func watch(listID: String) async { + guard !pendingWatchIDs.contains(listID), !watchedIDs.contains(listID) else { return } + pendingWatchIDs.insert(listID) + watchErrors[listID] = nil + watchedIDs.insert(listID) + defer { pendingWatchIDs.remove(listID) } + + do { + try await lists.watch(listId: listID) + } catch { + watchedIDs.remove(listID) + watchErrors[listID] = error.localizedDescription + } + } + + func isWatching(_ listID: String) -> Bool { watchedIDs.contains(listID) } + func isWatchPending(_ listID: String) -> Bool { pendingWatchIDs.contains(listID) } +} diff --git a/AppTests/ProfileViewModelTests.swift b/AppTests/ProfileViewModelTests.swift new file mode 100644 index 0000000..a47bd93 --- /dev/null +++ b/AppTests/ProfileViewModelTests.swift @@ -0,0 +1,195 @@ +// ProfileViewModelTests +// +// BDD-named tests for the My Profile work (GitHub #44 / G32): landing on the +// signed-in user's own profile, the guard that keeps `profileUnavailable` off +// it, and handle normalisation. +// +// The M1/M5 behaviours of this view model — the counts and mutual follow-ups, +// follow-button configuration — are covered by `ProfileHeaderViewModelTests`. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +// MARK: - My Profile (GitHub #44 / G32) +// +// Two behaviours, and the second is the one with teeth. +// +// Landing on your own profile is the feature: the session already knows who the +// user is, so opening Profile to an "enter a username" prompt made the one +// profile everybody wants to see the one that took the most typing to reach. +// +// The guard is that `profileUnavailable` must **never** fire for your own +// account. That state means "this user has no public messages, so there is +// nothing to project a profile from" — a statement about other people's public +// content. Shown for yourself, it tells a brand-new user with nothing posted yet +// that their own profile does not exist. + +@MainActor +final class ProfileViewModelTests: XCTestCase { + + /// A profile fixture with the content counts the live payload carries — + /// `publicMessageCount` and `publicListCount` were decoded by the kit and + /// dropped at the domain boundary before this work (GitHub #44). + static func profileFixture(id: String, username: String) -> UserProfile { + UserProfile( + summary: UserSummary(id: id, username: username, displayName: username, avatarURL: nil), + bio: "A bio", + followerCount: 1, + followingCount: 1, + publicMessageCount: 31, + publicListCount: 2 + ) + } + + + // Happy path + + func test_givenASignedInUser_whenTheProfileOpens_thenTheirOwnProfileLoads() async { + let social = StubSocialService() + await social.enqueueProfile(success: Self.profileFixture(id: "u-self", username: "ada")) + await social.enqueueCounts(success: FollowCounts(followers: 3, following: 4)) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { "u-self" }, + currentUsername: { "ada" } + ) + + await viewModel.loadOwnProfileIfNeeded() + + XCTAssertEqual(viewModel.loadedUsername, "ada") + XCTAssertEqual(viewModel.profile?.id, "u-self") + XCTAssertTrue(viewModel.isOwnProfile) + XCTAssertTrue(viewModel.isSignedIn) + } + + // Invalid / absent session + + func test_givenNoSession_whenTheProfileOpens_thenNothingIsLoaded() async { + let social = StubSocialService() + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { nil }, + currentUsername: { nil } + ) + + await viewModel.loadOwnProfileIfNeeded() + + XCTAssertNil(viewModel.loadedUsername) + XCTAssertFalse(viewModel.isSignedIn) + let recorded = await social.recorded + XCTAssertTrue(recorded.isEmpty, "signed out, there is no own profile to ask for") + } + + func test_givenAProfileAlreadyBrowsed_whenTheViewReappears_thenItIsNotYankedBack() async { + // Re-entering the tab must not pull the user off whoever they were + // looking at. + let social = StubSocialService() + await social.enqueueProfile(success: Self.profileFixture(id: "u-other", username: "bob")) + await social.enqueueCounts(success: FollowCounts(followers: 0, following: 0)) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { "u-self" }, + currentUsername: { "ada" } + ) + await viewModel.loadProfile(username: "bob") + + await viewModel.loadOwnProfileIfNeeded() + + XCTAssertEqual(viewModel.loadedUsername, "bob") + XCTAssertFalse(viewModel.isOwnProfile) + } + + // Upstream failure — the guard + + func test_givenOwnProfileUnavailable_whenLoading_thenTheEmptyStateIsSuppressed() async { + // A new account with nothing posted must not be told its own profile + // does not exist. + let social = StubSocialService() + await social.enqueueProfile(failure: SocialError.profileUnavailable(username: "ada")) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { "u-self" }, + currentUsername: { "ada" } + ) + + await viewModel.loadProfile(username: "ada") + + XCTAssertNil(viewModel.error, "profileUnavailable can never fire for your own account") + } + + func test_givenSomeoneElsesProfileUnavailable_whenLoading_thenTheEmptyStateStillFires() async { + // The other direction: suppressing it universally would hide a real, + // accurate explanation for someone else's empty profile. + let social = StubSocialService() + await social.enqueueProfile(failure: SocialError.profileUnavailable(username: "bob")) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { "u-self" }, + currentUsername: { "ada" } + ) + + await viewModel.loadProfile(username: "bob") + + guard case SocialError.profileUnavailable? = viewModel.error as? SocialError else { + return XCTFail("expected profileUnavailable, got \(String(describing: viewModel.error))") + } + } + + // Boundary — handle normalisation + + func test_givenMixedCaseAndAtPrefixedHandles_whenNormalising_thenTheyResolveTheSame() { + // Handles are case-insensitive on the site (`/user/adron` == `/user/Adron`) + // and `/@username` is a documented shortcut, so a deep link in any of + // these forms has to land. + XCTAssertEqual(ProfileViewModel.normalizedHandle("Adron"), "adron") + XCTAssertEqual(ProfileViewModel.normalizedHandle("@Adron"), "adron") + XCTAssertEqual(ProfileViewModel.normalizedHandle(" @ADRON "), "adron") + XCTAssertEqual(ProfileViewModel.normalizedHandle("adron"), "adron") + // Legal username punctuation is passed through untouched rather than + // sanitised against a charset the client would only get subtly wrong. + XCTAssertEqual(ProfileViewModel.normalizedHandle("@a.b-c_d"), "a.b-c_d") + XCTAssertEqual(ProfileViewModel.normalizedHandle("@"), "") + } + + func test_givenAnAtPrefixedHandle_whenLoading_thenTheNormalisedFormIsRequested() async { + let social = StubSocialService() + await social.enqueueProfile(success: Self.profileFixture(id: "u-1", username: "ada")) + await social.enqueueCounts(success: FollowCounts(followers: 0, following: 0)) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { nil }, + currentUsername: { nil } + ) + + await viewModel.loadProfile(username: "@Ada") + + XCTAssertEqual(viewModel.loadedUsername, "ada") + } + + // The ownership check is on id, not handle + + func test_givenARenamedAccount_whenComparingOwnership_thenTheIdDecides() async { + // A handle comparison would go wrong exactly where it matters — on a + // rename, where the session's cached handle and the profile's disagree. + let social = StubSocialService() + await social.enqueueProfile(success: Self.profileFixture(id: "u-self", username: "ada-new")) + await social.enqueueCounts(success: FollowCounts(followers: 0, following: 0)) + let viewModel = ProfileViewModel( + social: social, + relationshipReader: StubFollowRelationshipReader(), + currentUserID: { "u-self" }, + currentUsername: { "ada-old" } + ) + + await viewModel.loadProfile(username: "ada-new") + + XCTAssertTrue(viewModel.isOwnProfile) + } +} diff --git a/AppTests/PublicUserListsViewModelTests.swift b/AppTests/PublicUserListsViewModelTests.swift new file mode 100644 index 0000000..b7b433e --- /dev/null +++ b/AppTests/PublicUserListsViewModelTests.swift @@ -0,0 +1,149 @@ +// PublicUserListsViewModelTests +// +// BDD quartet for the public-lists column on a profile (GitHub #44 / G32). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class PublicUserListsViewModelTests: XCTestCase { + + private func summary(_ id: String, _ title: String) -> ListSummary { + ListSummary(id: id, title: title, description: nil) + } + + private func page(_ lists: [ListSummary]) -> ListsPage { + ListsPage(lists: lists, hasMore: false, nextOffset: nil) + } + + // MARK: - Happy path + + func test_givenPublicLists_whenLoading_thenRowsArriveForThatHandle() async { + // Given + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([summary("L1", "Films"), summary("L2", "Books")])) + let viewModel = PublicUserListsViewModel(lists: stub) + + // When + await viewModel.load(username: "ada") + + // Then + XCTAssertEqual(viewModel.listsLoaded.map(\.id), ["L1", "L2"]) + XCTAssertTrue(viewModel.hasLoaded) + XCTAssertNil(viewModel.error) + let recorded = await stub.recorded + guard case .publicLists(let username, _, _)? = recorded.first?.kind else { + return XCTFail("expected publicLists, got \(String(describing: recorded.first))") + } + XCTAssertEqual(username, "ada") + } + + // MARK: - Invalid input — never spend a round-trip on an empty handle + + func test_givenBlankHandle_whenLoading_thenNoCallIsMade() async { + let stub = StubListsService() + let viewModel = PublicUserListsViewModel(lists: stub) + + await viewModel.load(username: " ") + + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertFalse(viewModel.hasLoaded, "nothing was asked, so nothing is known") + } + + // MARK: - Upstream failure + + func test_givenServiceFailure_whenLoading_thenErrorIsSurfacedAndRowsAreCleared() async { + let stub = StubListsService() + await stub.enqueuePublicLists(failure: TestError.upstream("boom")) + let viewModel = PublicUserListsViewModel(lists: stub) + + await viewModel.load(username: "ada") + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.listsLoaded.isEmpty) + XCTAssertTrue(viewModel.hasLoaded, "the load completed — it completed badly") + } + + // MARK: - Boundary — no public lists is not the same as not asked + + func test_givenNoPublicLists_whenLoading_thenEmptyIsDistinguishableFromUnasked() async { + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([])) + let viewModel = PublicUserListsViewModel(lists: stub) + + XCTAssertFalse(viewModel.hasLoaded) + await viewModel.load(username: "ada") + + XCTAssertTrue(viewModel.isEmpty) + XCTAssertTrue(viewModel.hasLoaded) + XCTAssertNil(viewModel.error) + } + + // MARK: - Watch + + func test_givenAPublicList_whenWatching_thenTheRowFlipsAndTheCallIsMade() async { + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([summary("L1", "Films")])) + await stub.enqueueWatch() + let viewModel = PublicUserListsViewModel(lists: stub) + await viewModel.load(username: "ada") + + await viewModel.watch(listID: "L1") + + XCTAssertTrue(viewModel.isWatching("L1")) + XCTAssertFalse(viewModel.isWatchPending("L1")) + XCTAssertNil(viewModel.watchErrors["L1"]) + let recorded = await stub.recorded + XCTAssertTrue(recorded.contains { if case .watch(let id) = $0.kind { return id == "L1" } else { return false } }) + } + + func test_givenAFailedWatch_whenWriting_thenTheOptimisticFlipRollsBack() async { + // The rollback matters more than the optimism: a button that claims + // success and silently did nothing is worse than a slow one. + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([summary("L1", "Films")])) + await stub.enqueueWatch(failure: TestError.upstream("nope")) + let viewModel = PublicUserListsViewModel(lists: stub) + await viewModel.load(username: "ada") + + await viewModel.watch(listID: "L1") + + XCTAssertFalse(viewModel.isWatching("L1"), "the optimistic flip is undone") + XCTAssertNotNil(viewModel.watchErrors["L1"], "and the failure is reported on that row") + } + + func test_givenAlreadyWatching_whenWatchingAgain_thenNoSecondCallIsMade() async { + // Boundary: a double-press must not fire two writes. + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([summary("L1", "Films")])) + await stub.enqueueWatch() + let viewModel = PublicUserListsViewModel(lists: stub) + await viewModel.load(username: "ada") + + await viewModel.watch(listID: "L1") + await viewModel.watch(listID: "L1") + + let watchCalls = await stub.recorded.filter { if case .watch = $0.kind { return true } else { return false } } + XCTAssertEqual(watchCalls.count, 1) + } + + func test_givenAFailedWatchOnOneRow_whenAnotherSucceeds_thenTheErrorStaysOnItsOwnRow() async { + // The failure is reported against the row that caused it, not the column. + let stub = StubListsService() + await stub.enqueuePublicLists(success: page([summary("L1", "Films"), summary("L2", "Books")])) + await stub.enqueueWatch(failure: TestError.upstream("nope")) + await stub.enqueueWatch() + let viewModel = PublicUserListsViewModel(lists: stub) + await viewModel.load(username: "ada") + + await viewModel.watch(listID: "L1") + await viewModel.watch(listID: "L2") + + XCTAssertNotNil(viewModel.watchErrors["L1"]) + XCTAssertNil(viewModel.watchErrors["L2"]) + XCTAssertFalse(viewModel.isWatching("L1")) + XCTAssertTrue(viewModel.isWatching("L2")) + } +} diff --git a/AppTests/Support/StubListsService.swift b/AppTests/Support/StubListsService.swift index 969acf6..614dbf9 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -14,6 +14,7 @@ import InterlinedDomain struct RecordedListsCall: Sendable, Equatable { enum Kind: Sendable, Equatable { case publicLists(username: String, limit: Int, offset: Int) + case watch(listId: String) case publicList(username: String, slug: String) case publicRows(username: String, slug: String, limit: Int, offset: Int) case myLists(limit: Int, offset: Int) @@ -53,6 +54,8 @@ actor StubListsService: ListsServicing { private var deleteOutcomes: [Result] = [] private var detailOutcomes: [Result] = [] private var schemaOutcomes: [Result] = [] + private var publicListsOutcomes: [Result] = [] + private var watchOutcomes: [Result] = [] private var updateSchemaOutcomes: [Result] = [] private var refreshOutcomes: [Result] = [] private var rowsOutcomes: [Result] = [] @@ -107,6 +110,12 @@ actor StubListsService: ListsServicing { func enqueueSchema(success schema: ListSchema) { schemaOutcomes.append(.success(schema)) } func enqueueSchema(failure error: Error) { schemaOutcomes.append(.failure(error)) } + func enqueuePublicLists(success page: ListsPage) { publicListsOutcomes.append(.success(page)) } + func enqueuePublicLists(failure error: Error) { publicListsOutcomes.append(.failure(error)) } + + func enqueueWatch(success: Void = ()) { watchOutcomes.append(.success(())) } + func enqueueWatch(failure error: Error) { watchOutcomes.append(.failure(error)) } + func enqueueUpdateSchema(success schema: ListSchema) { updateSchemaOutcomes.append(.success(schema)) } func enqueueUpdateSchema(failure error: Error) { updateSchemaOutcomes.append(.failure(error)) } @@ -160,7 +169,12 @@ actor StubListsService: ListsServicing { func publicLists(username: String, limit: Int, offset: Int) async throws -> ListsPage { recorded.append(.init(kind: .publicLists(username: username, limit: limit, offset: offset))) - throw StubError.notProgrammed("publicLists") + return try take(&publicListsOutcomes, label: "publicLists") + } + + func watch(listId: String) async throws { + recorded.append(.init(kind: .watch(listId: listId))) + let _: Void = try take(&watchOutcomes, label: "watch") } func publicList(username: String, slug: String) async throws -> ListDetail { diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift index 4a0ef2d..31e8447 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift @@ -69,7 +69,13 @@ extension UserProfile { followerCount: dto.followerCount, followingCount: dto.followingCount, isPrivate: dto.isPrivate ?? false, - joinedAt: dto.joinedAt + joinedAt: dto.joinedAt, + // Decoded by the kit since the endpoint shipped and dropped here, + // so the header could only ever show two of the web profile's five + // stat tiles (GitHub #44). + publicMessageCount: dto.publicMessageCount, + publicListCount: dto.publicListCount, + headerImageURL: dto.headerImage.flatMap(URL.init(string:)) ) } @@ -87,7 +93,12 @@ extension UserProfile { followerCount: counts.followers, followingCount: counts.following, isPrivate: isPrivate, - joinedAt: joinedAt + joinedAt: joinedAt, + // Carried through: the follow-counts stitch is about followers and + // following, and must not blank the content counts on its way past. + publicMessageCount: publicMessageCount, + publicListCount: publicListCount, + headerImageURL: headerImageURL ) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift index 6bfafe3..b989a61 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift @@ -30,6 +30,26 @@ public struct UserProfile: Sendable, Equatable, Hashable, Identifiable { /// Account creation timestamp, when the API returns it. public let joinedAt: Date? + // MARK: Content counts (GitHub #44 / G32) + // + // `GET /api/users/{username}` has always returned these; the domain model + // dropped them at the boundary, so the header could only ever show two of + // the web profile's five stat tiles. Verified live 2026-09-15: + // + // {"followerCount":1,"followingCount":1, + // "publicMessageCount":31,"publicListCount":0} + + /// Public posts this user has published. `nil` when the route did not + /// supply it — which is **not** the same as zero, and the header renders + /// the tile only when a real number is known. + public let publicMessageCount: Int? + + /// Public lists this user owns. Same `nil` semantics as above. + public let publicListCount: Int? + + /// The profile banner image, when set. + public let headerImageURL: URL? + public var id: String { summary.id } public var username: String { summary.username } public var displayName: String { summary.displayName } @@ -41,7 +61,10 @@ public struct UserProfile: Sendable, Equatable, Hashable, Identifiable { followerCount: Int? = nil, followingCount: Int? = nil, isPrivate: Bool = false, - joinedAt: Date? = nil + joinedAt: Date? = nil, + publicMessageCount: Int? = nil, + publicListCount: Int? = nil, + headerImageURL: URL? = nil ) { self.summary = summary self.bio = bio @@ -49,6 +72,9 @@ public struct UserProfile: Sendable, Equatable, Hashable, Identifiable { self.followingCount = followingCount self.isPrivate = isPrivate self.joinedAt = joinedAt + self.publicMessageCount = publicMessageCount + self.publicListCount = publicListCount + self.headerImageURL = headerImageURL } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift index f3489e7..12ac5b6 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -189,6 +189,19 @@ public protocol ListsServicing: Sendable { notify: Bool ) async throws + /// Subscribes the **caller** to a public list — the Watch button on + /// someone else's profile (GitHub #44 / G32). + /// + /// This is the same route as `addWatcher`, taking its *self-subscribe* + /// branch by omitting `userId`. That branch is deliberately **free**: the + /// subscription gates granting someone else access, not following a list + /// that is already public to you. Modelled as its own method rather than an + /// optional parameter on `addWatcher`, because "add this person" and + /// "subscribe me" are different intents that happen to share a URL — and an + /// `addWatcher` call whose id went empty by accident must stay an error + /// rather than quietly becoming this. + func watch(listId: String) async throws + // MARK: - M3 watchers /// Loads every watcher on a list. Owner-only server-side. @@ -538,6 +551,13 @@ public final class ListsService: ListsServicing { } } + public func watch(listId: String) async throws { + // No `userId` — the self-subscribe branch. No entitlement check either: + // watching a public list is free, and gating it would make the Watch + // button on a public profile an upsell for something the web gives away. + _ = try await api.send(Lists.addWatcher(listId: listId, AddListWatcherRequest())) + } + // MARK: - M3 watchers public func watchers(of listId: String) async throws -> [ListWatcher] { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift index ded98aa..cdb7171 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift @@ -1236,3 +1236,79 @@ private actor FakeListsStore: ListsStore { func cacheRows(_ rows: [ListRow], of listId: String) async {} func clear() async { lists.removeAll() } } + +// MARK: - Watching a public list (GitHub #44 / G32) +// +// The Watch button on someone else's profile. Same route as `addWatcher`, taking +// its *self-subscribe* branch by omitting `userId` — and that branch is +// deliberately free: the subscription gates granting someone *else* access, not +// following a list that is already public to you. + +extension OwnedListsServiceTests { + + // Happy path + + func test_givenAPublicList_whenWatching_thenPostsWithoutAUserId() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"watching":true}"#) + let service = ListsService(api: api) + + try await service.watch(listId: "L1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/L1/watchers") + // The body must carry no `userId` — that is the whole difference between + // "subscribe me" and "grant that person access". `StubAPIClient` does not + // record request bodies on this branch, so the shape is asserted at the + // kit level instead; the empty-id guard below covers the confusion this + // could otherwise cause. + } + + // Free on every tier + + func test_givenAFreeAccount_whenWatching_thenItIsNotGated() async throws { + // Gating this would make the Watch button on a public profile an upsell + // for something the web gives away. + let api = StubAPIClient() + await api.enqueue(json: #"{"watching":true}"#) + let service = ListsService(api: api, entitlements: EntitlementsService(customerStatus: .free)) + + try await service.watch(listId: "L1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1, "the call is made, not refused") + } + + // Upstream failure + + func test_givenTheServerRefuses_whenWatching_thenTheErrorPropagates() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = ListsService(api: api) + + do { + try await service.watch(listId: "L1") + XCTFail("Expected the failure to propagate") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // Boundary — addWatcher's empty-id guard still holds, so the two intents + // cannot be confused by accident + + func test_givenAnEmptyUserId_whenAddingAWatcher_thenItIsRefusedRatherThanBecomingASelfSubscribe() async throws { + let api = StubAPIClient() + let service = ListsService(api: api) + + do { + try await service.addWatcher(listId: "L1", userId: " ", role: .viewer, notify: false) + XCTFail("Expected ListsError.invalidWatcher") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidWatcher) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "and no round-trip was spent finding out") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileContentCountsTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileContentCountsTests.swift new file mode 100644 index 0000000..5ec0efc --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileContentCountsTests.swift @@ -0,0 +1,113 @@ +// ProfileContentCountsTests +// +// The content counts on a public profile (GitHub #44 / G32). +// +// `GET /api/users/{username}` has always returned `publicMessageCount` and +// `publicListCount`, and `PublicProfileDTO` has always decoded them — they were +// dropped at the **domain** boundary, so the profile header could only ever show +// two of the web's five stat tiles. These tests pin the carry-through, including +// the place it is easiest to lose: the follow-counts stitch. + +import XCTest +@testable import InterlinedDomain +@testable import InterlinedKit + +final class ProfileContentCountsTests: XCTestCase { + + /// The live payload, captured 2026-09-15 from the test account. + private let profileJSON = """ + { + "id": "15e3d575-98bc-40e5-9aba-0d9cc9e30799", + "username": "messenger", + "displayName": "Messenger & Recon @ InterlinedList", + "avatar": "https://example.com/avatar.jpg", + "headerImage": null, + "bio": "Post it once, send it everywhere.", + "joinedAt": "2026-03-23T23:23:59.755Z", + "isPrivate": false, + "followerCount": 1, + "followingCount": 1, + "publicMessageCount": 31, + "publicListCount": 0 + } + """ + + // MARK: - Happy path + + func test_givenTheLiveProfilePayload_whenMapping_thenTheContentCountsSurvive() throws { + let dto = try JSONCoders.makeDecoder().decode( + PublicProfileDTO.self, + from: Data(profileJSON.utf8) + ) + + let profile = UserProfile(from: dto) + + XCTAssertEqual(profile.publicMessageCount, 31) + XCTAssertEqual(profile.publicListCount, 0) + XCTAssertEqual(profile.followerCount, 1) + XCTAssertEqual(profile.followingCount, 1) + } + + // MARK: - The stitch must not blank them + + func test_givenAFollowCountsStitch_whenApplied_thenTheContentCountsAreCarriedThrough() throws { + // `withCounts` exists to refresh followers/following from the dedicated + // counts call. Rebuilding the profile without carrying the content + // counts would blank two tiles every time that follow-up landed — a + // regression that would look like flicker, not like a bug. + let dto = try JSONCoders.makeDecoder().decode( + PublicProfileDTO.self, + from: Data(profileJSON.utf8) + ) + let profile = UserProfile(from: dto) + + let stitched = profile.withCounts(FollowCounts(followers: 9, following: 8)) + + XCTAssertEqual(stitched.followerCount, 9, "the stitch is what refreshes these") + XCTAssertEqual(stitched.followingCount, 8) + XCTAssertEqual(stitched.publicMessageCount, 31, "and must not blank these on the way past") + XCTAssertEqual(stitched.publicListCount, 0) + } + + // MARK: - Boundary — absent is not zero + + func test_givenAPayloadWithoutCounts_whenMapping_thenTheyAreNilNotZero() throws { + // "0 posts" and "we could not find out how many posts" look identical + // and mean opposite things. A tile is omitted for `nil`, so the + // distinction has to survive mapping. + let json = """ + { "id": "u1", "username": "ada" } + """ + let dto = try JSONCoders.makeDecoder().decode(PublicProfileDTO.self, from: Data(json.utf8)) + + let profile = UserProfile(from: dto) + + XCTAssertNil(profile.publicMessageCount) + XCTAssertNil(profile.publicListCount) + XCTAssertNil(profile.headerImageURL) + } + + // MARK: - Invalid — the reduced-scope fallback knows none of this + + func test_givenTheEmbeddedAuthorFallback_whenMapping_thenContentCountsAreNil() { + // Decision 0002's fallback projects identity from a message's author and + // genuinely cannot know these. Defaulting them to zero there would + // publish a confident wrong number. + let json = """ + { + "id": "m1", "content": "hi", "publiclyVisible": true, + "userId": "u1", + "createdAt": "2026-09-15T10:00:00.000Z", + "updatedAt": "2026-09-15T10:00:00.000Z", + "digCount": 0, "pushCount": 0, "dugByMe": false, + "user": {"id":"u1","username":"ada","displayName":"Ada","avatar":null} + } + """ + let message = try! JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + + let profile = UserProfile(fromEmbeddedAuthorOf: message) + + XCTAssertNil(profile.publicMessageCount) + XCTAssertNil(profile.publicListCount) + } +}