Skip to content

Add unfollow / delete-follow-by-subject to the test mocks (MockAtmosphere / MockPDS / MockRepo) #77

Description

@germ-mark

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

  • MockAtmosphere.unfollow(subjectDid:from:) removes a follow created by follow(subjectDid:from:).
  • After unfollow, getGraph(did:) for the viewer no longer lists the subject.
  • After unfollow, the subject no longer appears in the actor's getFollowers / getKnownFollowers.
  • Idempotent: unfollowing a non-existent follow is a no-op (no throw).

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions