From 5a80abfee6e76ac48a7bdad32db84028cb072c00 Mon Sep 17 00:00:00 2001 From: "Mark (dev)" Date: Thu, 16 Jul 2026 15:24:11 -0700 Subject: [PATCH 1/4] Add unfollow to the mock repo and MockPDS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inverse of `follow`: `MockRepo.unfollow(did:)` removes every follow record whose subject matches, and `MockPDS.unfollow(did:from:)` delegates to it. Lets a test construct a "not followed" social-graph state — the anchor welcome route's silence-and-relay case — which was previously unreachable (the mocks had follow and block, but no way to undo a follow). MockAtmosphere parity lands in AtprotoOAuth. Co-Authored-By: Claude Opus 4.8 --- .changeset/mock-unfollow.md | 5 +++++ Sources/AtprotoClientMocks/MockPDS/MockPDS.swift | 6 ++++++ Sources/AtprotoClientMocks/MockPDS/MockRepo.swift | 14 ++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 .changeset/mock-unfollow.md diff --git a/.changeset/mock-unfollow.md b/.changeset/mock-unfollow.md new file mode 100644 index 0000000..7788bfd --- /dev/null +++ b/.changeset/mock-unfollow.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Add `unfollow(did:)` to the mock repo and `unfollow(did:from:)` to MockPDS — the inverse of `follow`, removing the matching follow record. Lets tests construct a "not followed" social-graph state (the anchor-route silence-and-relay case). MockAtmosphere parity is added in AtprotoOAuth. diff --git a/Sources/AtprotoClientMocks/MockPDS/MockPDS.swift b/Sources/AtprotoClientMocks/MockPDS/MockPDS.swift index 41c2928..f764eb1 100644 --- a/Sources/AtprotoClientMocks/MockPDS/MockPDS.swift +++ b/Sources/AtprotoClientMocks/MockPDS/MockPDS.swift @@ -399,4 +399,10 @@ extension MockPDS { .tryUnwrap .block(did: did) } + + public func unfollow(did: Atproto.DID, from viewer: Atproto.DID) async throws { + try await repos[viewer] + .tryUnwrap + .unfollow(did: did) + } } diff --git a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift index 4cc982a..1262890 100644 --- a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift +++ b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift @@ -310,4 +310,18 @@ extension MockRepo { .encode(Lexicon.App.Bsky.Graph.Block(subject: did)) ) } + + //Remove every follow record whose subject is `did` (the inverse of `follow`). + //A no-op if none exist. Iterate a snapshot so the delete doesn't mutate the + //dictionary being read. + public func unfollow(did: Atproto.DID) throws { + let collection = Lexicon.App.Bsky.Graph.Follow.Collection.nsid + for (rkey, encoded) in untypedRepo[collection] ?? [:] { + let follow = try JSONDecoder() + .decode(Lexicon.App.Bsky.Graph.Follow.self, from: encoded) + if follow.subject == did { + untypedRepo[collection]?[rkey] = nil + } + } + } } From 5aee90be47e9d95cc927cec3ebc9f8938477f1dc Mon Sep 17 00:00:00 2001 From: "Mark (dev)" Date: Thu, 16 Jul 2026 16:04:05 -0700 Subject: [PATCH 2/4] Skip undecodable records in the mock graph read and unfollow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `getGraph` and the new `unfollow` decoded every record in a graph collection with `try`, so a single foreign or newer-schema record would abort the whole read / unfollow. Decode best-effort instead — skip a record that doesn't parse as its type rather than throwing on it — so one bad record can't strand the follows/blocks that do decode. Neither can fail now, so both drop `throws`. A test injects an un-decodable record alongside a real follow and checks getGraph still returns the good one and unfollow still removes it. Co-Authored-By: Claude Opus 4.8 --- .../AtprotoClientMocks/MockPDS/MockRepo.swift | 34 ++++++++------ .../MockRepoResilienceTests.swift | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 Tests/AtprotoClientTests/MockRepoResilienceTests.swift diff --git a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift index 1262890..6880990 100644 --- a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift +++ b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift @@ -272,21 +272,24 @@ extension MockRepo { } extension MockRepo { - func getGraph() throws -> ( + func getGraph() -> ( [Lexicon.App.Bsky.Graph.Follow], [Lexicon.App.Bsky.Graph.Block] ) { - let follows = try + //Best-effort decode: skip any record that doesn't parse as its type rather + //than failing the whole read on one bad/foreign record (matches `unfollow`). + let follows = (untypedRepo[Lexicon.App.Bsky.Graph.Follow.Collection.nsid] ?? [:]) .values - .map { - try JSONDecoder().decode( + .compactMap { + try? JSONDecoder().decode( Lexicon.App.Bsky.Graph.Follow.self, from: $0) } - let blocks = try (untypedRepo[Lexicon.App.Bsky.Graph.Block.Collection.nsid] ?? [:]) + let blocks = + (untypedRepo[Lexicon.App.Bsky.Graph.Block.Collection.nsid] ?? [:]) .values - .map { - try JSONDecoder().decode( + .compactMap { + try? JSONDecoder().decode( Lexicon.App.Bsky.Graph.Block.self, from: $0) } @@ -313,15 +316,18 @@ extension MockRepo { //Remove every follow record whose subject is `did` (the inverse of `follow`). //A no-op if none exist. Iterate a snapshot so the delete doesn't mutate the - //dictionary being read. - public func unfollow(did: Atproto.DID) throws { + //dictionary being read. Decoding is best-effort: a record that doesn't parse + //as a Follow can't be the one we're removing, and must not abort the removal + //of the ones that do — so skip it rather than throw. + public func unfollow(did: Atproto.DID) { let collection = Lexicon.App.Bsky.Graph.Follow.Collection.nsid for (rkey, encoded) in untypedRepo[collection] ?? [:] { - let follow = try JSONDecoder() - .decode(Lexicon.App.Bsky.Graph.Follow.self, from: encoded) - if follow.subject == did { - untypedRepo[collection]?[rkey] = nil - } + guard + let follow = try? JSONDecoder() + .decode(Lexicon.App.Bsky.Graph.Follow.self, from: encoded), + follow.subject == did + else { continue } + untypedRepo[collection]?[rkey] = nil } } } diff --git a/Tests/AtprotoClientTests/MockRepoResilienceTests.swift b/Tests/AtprotoClientTests/MockRepoResilienceTests.swift new file mode 100644 index 0000000..68fb730 --- /dev/null +++ b/Tests/AtprotoClientTests/MockRepoResilienceTests.swift @@ -0,0 +1,45 @@ +// +// MockRepoResilienceTests.swift +// AtprotoClientTests +// +// A single un-decodable record in a graph collection (a foreign/malformed +// record, or one written by a newer schema) must not break the whole read or +// the unfollow. Both `getGraph` and `unfollow` decode best-effort: they skip a +// record they can't parse rather than throwing on it. Before that fix, either +// would abort on the first bad record. +// + +import AtprotoClient +import AtprotoTypes +import Foundation +import Testing + +@testable import AtprotoClientMocks + +struct MockRepoResilienceTests { + @Test func graphReadAndUnfollowSkipUndecodableFollowRecords() async throws { + let repo = try MockRepo() + let collection = Lexicon.App.Bsky.Graph.Follow.Collection.nsid + + let keep = Atproto.DID.mock() + try await repo.follow(did: keep) + + //a record that is NOT a valid Follow, sitting in the Follow collection + try await repo.putRecord( + collection: collection, + rkey: "not-a-follow", + encodedRecord: Data(#"{"unexpected":"shape"}"#.utf8) + ) + + //getGraph must not throw on the bad record, and returns the one it can read + let (follows, _) = await repo.getGraph() + #expect(follows.count == 1) + #expect(follows.first?.subject == keep) + + //unfollow must not throw either, and removes the real follow while the + //un-decodable record (which it can't match) is simply skipped + await repo.unfollow(did: keep) + let (afterFollows, _) = await repo.getGraph() + #expect(afterFollows.isEmpty) + } +} From 1f06585be72b6d6bddc19337e9e6273f3fd3090c Mon Sep 17 00:00:00 2001 From: "Mark (dev)" Date: Thu, 16 Jul 2026 16:07:53 -0700 Subject: [PATCH 3/4] Bind the unfollow snapshot to a let so the intent is explicit The loop already iterated a snapshot (untypedRepo[collection] ?? [:] is evaluated once and copy-on-write isolates it from the in-loop deletes), but that relied on value semantics a reader had to infer. Name it. Co-Authored-By: Claude Opus 4.8 --- Sources/AtprotoClientMocks/MockPDS/MockRepo.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift index 6880990..1fbb549 100644 --- a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift +++ b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift @@ -315,13 +315,16 @@ extension MockRepo { } //Remove every follow record whose subject is `did` (the inverse of `follow`). - //A no-op if none exist. Iterate a snapshot so the delete doesn't mutate the - //dictionary being read. Decoding is best-effort: a record that doesn't parse - //as a Follow can't be the one we're removing, and must not abort the removal - //of the ones that do — so skip it rather than throw. + //A no-op if none exist. Bind the collection to a `let` first so the loop + //iterates that snapshot while the deletes below mutate `untypedRepo` — the two + //are separate values (copy-on-write), so removing entries can't disturb the + //iteration. Decoding is best-effort: a record that doesn't parse as a Follow + //can't be the one we're removing, and must not abort the removal of the ones + //that do — so skip it rather than throw. public func unfollow(did: Atproto.DID) { let collection = Lexicon.App.Bsky.Graph.Follow.Collection.nsid - for (rkey, encoded) in untypedRepo[collection] ?? [:] { + let records = untypedRepo[collection] ?? [:] + for (rkey, encoded) in records { guard let follow = try? JSONDecoder() .decode(Lexicon.App.Bsky.Graph.Follow.self, from: encoded), From 64e1d974feae00b27f0d6a9fd4c6dcdd09e718c4 Mon Sep 17 00:00:00 2001 From: germ-mark <123763184+germ-mark@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:10:21 -0700 Subject: [PATCH 4/4] Update MockRepo.swift --- Sources/AtprotoClientMocks/MockPDS/MockRepo.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift index 1fbb549..10f5977 100644 --- a/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift +++ b/Sources/AtprotoClientMocks/MockPDS/MockRepo.swift @@ -315,10 +315,7 @@ extension MockRepo { } //Remove every follow record whose subject is `did` (the inverse of `follow`). - //A no-op if none exist. Bind the collection to a `let` first so the loop - //iterates that snapshot while the deletes below mutate `untypedRepo` — the two - //are separate values (copy-on-write), so removing entries can't disturb the - //iteration. Decoding is best-effort: a record that doesn't parse as a Follow + //A no-op if none exist. Decoding is best-effort: a record that doesn't parse as a Follow //can't be the one we're removing, and must not abort the removal of the ones //that do — so skip it rather than throw. public func unfollow(did: Atproto.DID) {