Add unfollow / delete-follow-by-subject to the test mocks (MockAtmosphere / MockPDS / MockRepo)
Repos: germ-network/AtprotoClient (primary — AtprotoClientMocks), germ-network/AtprotoOAuth (AtprotoOAuthMocks, convenience wrapper)
Summary
The mock backend can create follow records but can't remove them. There's MockAtmosphere.follow(subjectDid:from:) (and the underlying MockPDS.follow(did:from:) → MockRepo.follow(did:)), but no symmetric unfollow. This makes it impossible to write a test that breaks an existing relationship — e.g. demoting a mutual to a one-way follow.
Why it's needed
We added a derived isMutual flag to follow edges that is recomputed from follows ∩ followers on each refresh. The flag has two transitions to test: set (becomes mutual) and clear (a mutual is broken). We can test set today, but we cannot test clear faithfully, because there's no way to make a previously-reciprocal follow non-reciprocal in the mock. We currently work around it by injecting state directly into our store, which only verifies an end-state invariant and doesn't exercise the real backend transition.
Current gap (what blocks a clean solution)
MockRepo.follow(did:) stores the record under a random UUID rkey (AtprotoClientMocks/MockPDS/MockRepo.swift):
public func follow(did: Atproto.DID) throws {
try putRecord(
collection: Lexicon.App.Bsky.Graph.Follow.Collection.nsid,
rkey: UUID().uuidString,
encodedRecord: try JSONEncoder().encode(Lexicon.App.Bsky.Graph.Follow(subject: did))
)
}
MockRepo.deleteRecord(collection:rkey:) exists but needs the rkey.
getGraph() returns decoded [Follow] records without their rkeys, so a caller can't find the record to delete by subject.
So even though deletion is supported at the record level, there's no exposed path to "remove the follow whose subject is X."
Proposed API
A subject-keyed delete that mirrors follow:
// MockRepo (AtprotoClientMocks)
// Deletes every app.bsky.graph.follow record whose subject == did. No-op if none.
public func deleteFollow(subject did: Atproto.DID) throws
// MockPDS (AtprotoClientMocks)
public func unfollow(did: Atproto.DID, from viewer: Atproto.DID) async throws
// MockAtmosphere (AtprotoOAuthMocks) — convenience mirroring follow(subjectDid:from:)
public func unfollow(subjectDid: Atproto.DID, from viewerDid: Atproto.DID) async throws
Sketch
MockRepo.deleteFollow(subject:) scans the follow collection in untypedRepo, decodes each record, and removes the keys whose subject == did:
public func deleteFollow(subject did: Atproto.DID) throws {
let nsid = Lexicon.App.Bsky.Graph.Follow.Collection.nsid
let staleKeys = try (untypedRepo[nsid] ?? [:]).compactMap { key, data -> EncodedRecordKey? in
try JSONDecoder().decode(Lexicon.App.Bsky.Graph.Follow.self, from: data).subject == did ? key : nil
}
for key in staleKeys { untypedRepo[nsid]?[key] = nil }
}
getFollowers/getKnownFollowers are derived live from stored records (MockBlueskyService.computeKnownFollowers), so the removal is reflected on the next fetch with no cache to invalidate.
Acceptance criteria
Notes
- Optional but nice: also expose
deleteBlock(subject:) for symmetry with blocks.
- Mirrors real AT semantics (deleting the
app.bsky.graph.follow record) closely enough for graph tests.
Add
unfollow/ delete-follow-by-subject to the test mocks (MockAtmosphere/MockPDS/MockRepo)Repos:
germ-network/AtprotoClient(primary —AtprotoClientMocks),germ-network/AtprotoOAuth(AtprotoOAuthMocks, convenience wrapper)Summary
The mock backend can create follow records but can't remove them. There's
MockAtmosphere.follow(subjectDid:from:)(and the underlyingMockPDS.follow(did:from:)→MockRepo.follow(did:)), but no symmetric unfollow. This makes it impossible to write a test that breaks an existing relationship — e.g. demoting a mutual to a one-way follow.Why it's needed
We added a derived
isMutualflag to follow edges that is recomputed fromfollows ∩ followerson each refresh. The flag has two transitions to test: set (becomes mutual) and clear (a mutual is broken). We can test set today, but we cannot test clear faithfully, because there's no way to make a previously-reciprocal follow non-reciprocal in the mock. We currently work around it by injecting state directly into our store, which only verifies an end-state invariant and doesn't exercise the real backend transition.Current gap (what blocks a clean solution)
MockRepo.follow(did:)stores the record under a randomUUIDrkey (AtprotoClientMocks/MockPDS/MockRepo.swift):MockRepo.deleteRecord(collection:rkey:)exists but needs the rkey.getGraph()returns decoded[Follow]records without their rkeys, so a caller can't find the record to delete by subject.So even though deletion is supported at the record level, there's no exposed path to "remove the follow whose subject is X."
Proposed API
A subject-keyed delete that mirrors
follow:Sketch
MockRepo.deleteFollow(subject:)scans the follow collection inuntypedRepo, decodes each record, and removes the keys whosesubject == did:getFollowers/getKnownFollowersare derived live from stored records (MockBlueskyService.computeKnownFollowers), so the removal is reflected on the next fetch with no cache to invalidate.Acceptance criteria
MockAtmosphere.unfollow(subjectDid:from:)removes a follow created byfollow(subjectDid:from:).getGraph(did:)for the viewer no longer lists the subject.getFollowers/getKnownFollowers.Notes
deleteBlock(subject:)for symmetry with blocks.app.bsky.graph.followrecord) closely enough for graph tests.