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..10f5977 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) } @@ -310,4 +313,21 @@ 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. 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 + let records = untypedRepo[collection] ?? [:] + for (rkey, encoded) in records { + 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) + } +}