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) + } +}