diff --git a/server/test/connections_handler_test.gleam b/server/test/connections_handler_test.gleam index 6a2d93f..f641bfc 100644 --- a/server/test/connections_handler_test.gleam +++ b/server/test/connections_handler_test.gleam @@ -27,16 +27,6 @@ fn auth_session() -> sessions.OauthSession { sessions.OauthSession(..session, expires_at: 9_999_999_999) } -fn authed_get( - path: String, - cfg: config.Config, - session: sessions.OauthSession, -) -> wisp.Request { - let assert Ok(id) = session_store.create(cfg.sessions, session) - simulate.request(http.Get, path) - |> simulate.cookie(session_cookie, id, wisp.Signed) -} - fn context_with( client: xrpc.Client, follows: follow_index.Store, @@ -55,19 +45,22 @@ fn context_with( #(ctx, cfg) } -fn ok_response( - body: String, -) -> Result(response.Response(BitArray), xrpc.TransportError) { - Ok(response.Response(200, [], bit_array.from_string(body))) +fn respond( + follows: follow_index.Store, + client: xrpc.Client, + session: sessions.OauthSession, + query: String, +) -> wisp.Response { + let #(ctx, cfg) = context_with(client, follows) + let assert Ok(id) = session_store.create(cfg.sessions, session) + simulate.request(http.Get, support.xrpc(query)) + |> simulate.cookie(session_cookie, id, wisp.Signed) + |> connections.list_connections(ctx) } -fn list_records_client(body: String) -> xrpc.Client { - xrpc.Client(send: fn(req) { - case req.path { - "/xrpc/com.atproto.repo.listRecords" -> ok_response(body) - _ -> Error(core_xrpc.ConnectionFailed("unused")) - } - }) +fn followers_body(follows: follow_index.Store, query: String) -> String { + respond(follows, support.unreachable_client(), auth_session(), query) + |> simulate.read_body } fn item_bools(body: String, field: String) -> Result(List(Bool), Nil) { @@ -105,30 +98,38 @@ fn seed_followers( }) } -fn list_body(subjects: List(String)) -> String { - subjects - |> list.index_map(fn(subject, index) { - json.object([ - #( - "uri", - json.string( - "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/r" - <> int.to_string(index), +fn list_records_client(subjects: List(String)) -> xrpc.Client { + let body = + subjects + |> list.index_map(fn(subject, index) { + json.object([ + #( + "uri", + json.string( + "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/r" + <> int.to_string(index), + ), ), - ), - #("cid", json.string("cid" <> int.to_string(index))), - #( - "value", - json.object([ - #("subject", json.string(subject)), - #("createdAt", json.string("2026-01-01T00:00:00Z")), - ]), - ), - ]) + #("cid", json.string("cid" <> int.to_string(index))), + #( + "value", + json.object([ + #("subject", json.string(subject)), + #("createdAt", json.string("2026-01-01T00:00:00Z")), + ]), + ), + ]) + }) + |> json.preprocessed_array + |> fn(records) { json.object([#("records", records)]) } + |> json.to_string + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> + Ok(response.Response(200, [], bit_array.from_string(body))) + _ -> Error(core_xrpc.ConnectionFailed("unused")) + } }) - |> json.preprocessed_array - |> fn(records) { json.object([#("records", records)]) } - |> json.to_string } pub fn unauthenticated_connections_are_rejected_test() { @@ -142,86 +143,65 @@ pub fn unauthenticated_connections_are_rejected_test() { assert connections.list_connections(req, ctx).status == 401 } -pub fn followers_do_not_require_the_viewers_pds_test() { - let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:follower"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let resp = connections.list_connections(req, ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) - assert support.field_nested(body, ["items"], ["did"]) - == Ok(["did:plc:follower"]) - assert support.field_bool(body, ["relationshipComplete"]) == Ok(True) +// Followers are served from the index alone, so an unreachable PDS is fine; +// only `relationshipComplete` depends on having seen the viewer's own follows. +pub fn followers_never_need_the_viewers_pds_test() { + [#(True, True), #(False, False)] + |> list.each(fn(row) { + let #(viewer_seen, relationship_complete) = row + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:follower"]) + case viewer_seen { + True -> follows.seen.mark_seen("did:plc:me") + False -> Nil + } + let body = + followers_body(follows, "graph.listConnections?direction=followers") + assert support.field_nested(body, ["items"], ["did"]) + == Ok(["did:plc:follower"]) + assert support.field_bool(body, ["relationshipComplete"]) + == Ok(relationship_complete) + assert item_bools(body, "viewerFollows") == Ok([False]) + }) } -pub fn cold_followers_report_incomplete_relationships_test() { +pub fn an_unresolvable_identity_falls_back_to_the_did_test() { let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:follower"]) - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let body = simulate.read_body(connections.list_connections(req, ctx)) - assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) - assert item_bools(body, "viewerFollows") == Ok([False]) + seed_followers(follows, "did:plc:me", ["did:plc:unresolvable"]) + follows.seen.mark_seen("did:plc:me") + let body = + followers_body(follows, "graph.listConnections?direction=followers") + assert support.field_nested(body, ["items"], ["handle"]) + == Ok(["did:plc:unresolvable"]) } -pub fn following_pds_failure_is_upstream_failure_test() { +// Following reads through to the PDS, so its failure is upstream failure +// rather than a silent fall back to whatever the index still remembers. +pub fn following_reports_a_pds_failure_and_keeps_the_index_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=following"), - cfg, + let resp = + respond( + follows, + support.unreachable_client(), auth_session(), + "graph.listConnections?direction=following", ) - assert connections.list_connections(req, ctx).status == 502 + assert resp.status == 502 assert follows.edges.following("did:plc:me") == ["did:plc:stale"] } -pub fn identity_failure_falls_back_to_the_did_test() { - let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:unresolvable"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let body = simulate.read_body(connections.list_connections(req, ctx)) - assert support.field_nested(body, ["items"], ["handle"]) - == Ok(["did:plc:unresolvable"]) -} - -pub fn following_uses_pds_data_and_marks_the_viewer_complete_test() { +pub fn following_replaces_the_index_with_pds_data_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) - let #(ctx, cfg) = - context_with( - list_records_client(list_body(["did:plc:first", "did:plc:second"])), + let resp = + respond( follows, - ) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=following&limit=1"), - cfg, + list_records_client(["did:plc:first", "did:plc:second"]), auth_session(), + "graph.listConnections?direction=following&limit=1", ) - let resp = connections.list_connections(req, ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_nested(body, ["items"], ["did"]) == Ok(["did:plc:first"]) @@ -229,52 +209,38 @@ pub fn following_uses_pds_data_and_marks_the_viewer_complete_test() { assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) assert support.field_present(body, ["cursor"]) assert follows.edges.following("did:plc:me") - == [ - "did:plc:first", - "did:plc:second", - ] + == ["did:plc:first", "did:plc:second"] } -pub fn connection_cursor_paginates_and_is_viewer_scoped_test() { +pub fn a_connection_cursor_paginates_and_is_viewer_scoped_test() { let follows = support.fresh_follow_index() seed_followers(follows, "did:plc:me", ["did:plc:a", "did:plc:b"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let first_req = - authed_get( - support.xrpc("graph.listConnections?direction=followers&limit=1"), - cfg, - auth_session(), - ) - let first_body = - simulate.read_body(connections.list_connections(first_req, ctx)) - let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) - let next_req = - authed_get( - support.xrpc( - "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, - ), - cfg, - auth_session(), - ) - let next_body = - simulate.read_body(connections.list_connections(next_req, ctx)) - assert support.field_nested(next_body, ["items"], ["did"]) - == Ok(["did:plc:b"]) - - let other = sessions.OauthSession(..auth_session(), did: "did:plc:other") seed_followers(follows, "did:plc:other", ["did:plc:c"]) + follows.seen.mark_seen("did:plc:me") follows.seen.mark_seen("did:plc:other") - let other_req = - authed_get( - support.xrpc( - "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, - ), - cfg, - other, - ) - let other_body = - simulate.read_body(connections.list_connections(other_req, ctx)) - assert support.field_nested(other_body, ["items"], ["did"]) - == Ok(["did:plc:c"]) + let first = + followers_body(follows, "graph.listConnections?direction=followers&limit=1") + let assert Ok(cursor) = support.field_string(first, ["cursor"]) + let paged = + "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor + + // The same cursor handed to a different viewer resumes in that viewer's + // own slice rather than leaking this one's. + [ + #(auth_session(), ["did:plc:b"]), + #(sessions.OauthSession(..auth_session(), did: "did:plc:other"), [ + "did:plc:c", + ]), + ] + |> list.each(fn(row) { + let #(session, expected) = row + let body = + simulate.read_body(respond( + follows, + support.unreachable_client(), + session, + paged, + )) + assert support.field_nested(body, ["items"], ["did"]) == Ok(expected) + }) } diff --git a/web/test/connections_test.gleam b/web/test/connections_test.gleam index e0ab1ba..f67f751 100644 --- a/web/test/connections_test.gleam +++ b/web/test/connections_test.gleam @@ -11,80 +11,93 @@ import crate_web/msg.{ import crate_web/pages/connections import crate_web/route import crate_web/update.{update} +import gleam/list import gleam/option.{None, Some} import gleam/string import gleam/uri import lustre/element import support.{empty_effect, logged_in, network_error} +fn connection(handle: String) -> Connection { + Connection( + did: "did:" <> handle, + handle: handle <> ".test", + viewer_follows: False, + follows_viewer: False, + mutual: False, + ) +} + +fn view_html(state: model.ConnectionsState, followers_tab: Bool) -> String { + connections.view( + model.Model(..logged_in(), connections: state), + followers_tab, + ) + |> element.to_string +} + +fn loaded_follow_state(follows: Bool) -> Model { + Model( + ..logged_in(), + route: ConnectionsFollowing, + connections: ConnectionsLoaded( + [Connection(..connection("bob"), viewer_follows: follows)], + None, + True, + True, + ), + ) +} + +fn connection_follows(model: Model) -> Bool { + case model.connections { + ConnectionsLoaded([first, ..], _, _, _) -> first.viewer_follows + _ -> False + } +} + pub fn connections_routes_round_trip_test() { assert route.to_path(ConnectionsFollowers) == "/connections/followers" let assert Ok(target) = uri.parse("/connections/followers") assert route.parse(target) == ConnectionsFollowers let assert Ok(default) = uri.parse("/connections") - assert route.parse(default) == model.ConnectionsFollowing + assert route.parse(default) == ConnectionsFollowing } -pub fn followers_empty_state_explains_index_incompleteness_test() { - let html = - connections.view( - model.Model( - ..logged_in(), - connections: ConnectionsLoaded([], None, False, True), - ), +// An empty follower list is ambiguous (nobody follows you vs the index has +// not caught up), so the empty state must not claim the former. +pub fn connection_states_render_distinctly_test() { + [ + #( + ConnectionsLoaded([], None, False, True), True, - ) - |> element.to_string - assert string.contains(html, "No indexed followers yet") - assert string.contains(html, "appview may still be catching up") - assert !string.contains(html, "No one follows you yet") -} - -pub fn connection_row_exposes_mutual_and_accessible_follow_action_test() { - let html = - connections.view( - model.Model( - ..logged_in(), - connections: ConnectionsLoaded( - [ - Connection( - did: "did:plc:bob", - handle: "bob.test", - viewer_follows: False, - follows_viewer: True, - mutual: False, - ), - ], - None, - False, - True, - ), + ["No indexed followers yet", "appview may still be catching up"], + ["No one follows you yet"], + ), + #( + ConnectionsLoaded( + [Connection(..connection("bob"), follows_viewer: True)], + None, + False, + True, ), True, - ) - |> element.to_string - assert string.contains(html, "@bob.test") - assert string.contains(html, "Follow @bob.test") - assert string.contains(html, "FOLLOW") - assert string.contains(html, "aria-current=\"page\"") -} - -pub fn connection_loading_and_failure_states_are_distinct_test() { - let loading = - connections.view( - model.Model(..logged_in(), connections: ConnectionsLoading), - False, - ) - |> element.to_string - let failed = - connections.view( - model.Model(..logged_in(), connections: ConnectionsFailed), - False, - ) - |> element.to_string - assert string.contains(loading, "LOADING CONNECTIONS") - assert string.contains(failed, "RETRY") - assert !string.contains(loading, "RETRY") + ["@bob.test", "Follow @bob.test", "FOLLOW", "aria-current=\"page\""], + [], + ), + #(ConnectionsLoading, False, ["LOADING CONNECTIONS"], ["RETRY"]), + #(ConnectionsFailed, False, ["RETRY"], []), + ] + |> list.each(fn(row) { + let #(state, followers_tab, present, absent) = row + let html = view_html(state, followers_tab) + list.each(present, fn(needle) { + assert string.contains(html, needle) + }) + list.each(absent, fn(needle) { + assert !string.contains(html, needle) + }) + }) } pub fn stale_same_route_list_response_is_ignored_after_retry_test() { @@ -163,36 +176,35 @@ pub fn pagination_response_appends_only_for_current_generation_test() { assert loaded.connections_loading_more == False } -pub fn optimistic_follow_success_clears_pending_write_test() { - let seeded = loaded_follow_state(False) - let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) - let assert Some(request) = optimistic.connection_pending - assert request.did == "did:bob" - assert request.desired == True - assert connection_follows(optimistic) == True - let #(confirmed, _) = - update(optimistic, GotConnectionFollow(request, Ok("at://alice/follow/1"))) - assert confirmed.connection_pending == None - assert connection_follows(confirmed) == True -} - -pub fn optimistic_follow_error_rolls_back_test() { - let seeded = loaded_follow_state(False) - let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) - let assert Some(request) = optimistic.connection_pending - let #(rolled_back, _) = - update(optimistic, GotConnectionFollow(request, Error(network_error()))) - assert rolled_back.connection_pending == None - assert connection_follows(rolled_back) == False - assert rolled_back.notice != None +// The row flips before the write lands; the response either confirms it or +// rolls it back with a notice. +pub fn an_optimistic_follow_is_confirmed_or_rolled_back_test() { + [ + #(Ok("at://alice/follow/1"), True, False), + #(Error(network_error()), False, True), + ] + |> list.each(fn(row) { + let #(outcome, follows_after, notice_expected) = row + let #(optimistic, _) = + update( + loaded_follow_state(False), + ToggleConnectionFollow("did:bob", False), + ) + let assert Some(request) = optimistic.connection_pending + assert request.did == "did:bob" + assert request.desired == True + assert connection_follows(optimistic) == True + let #(settled, _) = + update(optimistic, GotConnectionFollow(request, outcome)) + assert settled.connection_pending == None + assert connection_follows(settled) == follows_after + assert { settled.notice != None } == notice_expected + }) } pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { - let seeded = loaded_follow_state(False) let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) + update(loaded_follow_state(False), ToggleConnectionFollow("did:bob", False)) let assert Some(request) = optimistic.connection_pending let #(reloaded, _) = update(optimistic, OnRouteChange(ConnectionsFollowing)) assert reloaded.connection_pending == None @@ -202,66 +214,9 @@ pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { assert after_stale.connection_pending == None } -fn connection(handle: String) -> Connection { - Connection( - did: "did:" <> handle, - handle: handle <> ".test", - viewer_follows: False, - follows_viewer: False, - mutual: False, - ) -} - -fn loaded_follow_state(follows: Bool) -> Model { - Model( - ..logged_in(), - route: ConnectionsFollowing, - connections: ConnectionsLoaded( - [ - Connection( - did: "did:bob", - handle: "bob.test", - viewer_follows: follows, - follows_viewer: False, - mutual: False, - ), - ], - None, - True, - True, - ), - ) -} - -fn connection_follows(model: Model) -> Bool { - case model.connections { - ConnectionsLoaded([first, ..], _, _, _) -> first.viewer_follows - _ -> False - } -} - pub fn missing_follow_permission_offers_reauthorization_in_connections_test() { - let did = "did:plc:bob" - let model = - model.Model( - ..logged_in(), - route: ConnectionsFollowing, - connections: ConnectionsLoaded( - [ - Connection( - did:, - handle: "bob.test", - viewer_follows: False, - follows_viewer: False, - mutual: False, - ), - ], - None, - True, - True, - ), - ) - let #(pending, effect) = update(model, ToggleConnectionFollow(did, False)) + let #(pending, effect) = + update(loaded_follow_state(False), ToggleConnectionFollow("did:bob", False)) assert effect != empty_effect() let assert Some(request) = pending.connection_pending let #(updated, _) =