Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mock-unfollow.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions Sources/AtprotoClientMocks/MockPDS/MockPDS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
34 changes: 27 additions & 7 deletions Sources/AtprotoClientMocks/MockPDS/MockRepo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
}
}
45 changes: 45 additions & 0 deletions Tests/AtprotoClientTests/MockRepoResilienceTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading