~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
204 lines · 8.2 KB Raw History
import CryptoKit
@testable import Grain
import XCTest

/// Pins what a gallery delete removes from the account's repo.
///
/// Deleting the `social.grain.gallery` record on its own leaves the photos,
/// gallery items, exif records, and comments behind. The bug is invisible
/// from the client (the record does disappear), so it's worth asserting
/// every record that goes with the gallery is deleted with it.
final class GalleryServiceTests: GrainTestCase {
    private var recorder: Recorder!

    override func tearDown() {
        MockURLProtocol.handler = nil
        super.tearDown()
    }

    private static let gallery = "at://did:plc:test/social.grain.gallery/g1"

    /// What the account's repo holds, by collection, as `listRecords` pages.
    private static let repo: [String: String] = [
        "social.grain.gallery.item": #"""
        {"records": [
          {"uri": "at://did:plc:test/social.grain.gallery.item/i1", "value": {"gallery": "at://did:plc:test/social.grain.gallery/g1", "item": "at://did:plc:test/social.grain.photo/p1"}},
          {"uri": "at://did:plc:test/social.grain.gallery.item/i2", "value": {"gallery": "at://did:plc:test/social.grain.gallery/g1", "item": "at://did:plc:test/social.grain.photo/p2"}},
          {"uri": "at://did:plc:test/social.grain.gallery.item/i3", "value": {"gallery": "at://did:plc:test/social.grain.gallery/other", "item": "at://did:plc:test/social.grain.photo/p2"}}
        ]}
        """#,
        "social.grain.photo.exif": #"""
        {"records": [
          {"uri": "at://did:plc:test/social.grain.photo.exif/e1", "value": {"photo": "at://did:plc:test/social.grain.photo/p1"}},
          {"uri": "at://did:plc:test/social.grain.photo.exif/e2", "value": {"photo": "at://did:plc:test/social.grain.photo/p2"}}
        ]}
        """#,
        "social.grain.favorite": #"""
        {"records": [
          {"uri": "at://did:plc:test/social.grain.favorite/f1", "value": {"subject": "at://did:plc:test/social.grain.gallery/g1"}},
          {"uri": "at://did:plc:test/social.grain.favorite/f2", "value": {"subject": "at://did:plc:else/social.grain.gallery/x"}}
        ]}
        """#,
        "social.grain.comment": #"""
        {"records": [
          {"uri": "at://did:plc:test/social.grain.comment/c1", "value": {"subject": "at://did:plc:test/social.grain.gallery/g1", "text": "hi"}}
        ]}
        """#,
    ]

    private func makeClient(status: Int = 200) -> XRPCClient {
        let recorder = Recorder()
        self.recorder = recorder
        MockURLProtocol.handler = { request in
            recorder.record(path: request.url!.path, body: Self.body(of: request))
            let collection = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
                .queryItems?.first { $0.name == "collection" }?.value
            let body = request.url!.path.hasSuffix("listRecords")
                ? Self.repo[collection ?? ""] ?? #"{"records": []}"#
                : "{}"
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: status,
                httpVersion: nil,
                headerFields: ["Content-Type": "application/json"]
            )!
            return (Data(body.utf8), response)
        }
        return XRPCClient(baseURL: URL(string: "https://example.test")!, session: MockURLProtocol.mockSession())
    }

    private func makeAuth() -> AuthContext {
        AuthContext(accessToken: "test-token", dpop: DPoP(privateKey: P256.Signing.PrivateKey()), did: "did:plc:test", pds: URL(string: "https://test.local")!)
    }

    /// The deletes the one commit carried, as `collection/rkey`.
    private func deleted() throws -> [String] {
        let index = try XCTUnwrap(recorder.paths.firstIndex { $0.hasSuffix("com.atproto.repo.applyWrites") })
        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: recorder.bodies[recorder.bodyIndex(ofPath: index)]) as? [String: Any])
        let writes = try XCTUnwrap(json["writes"] as? [[String: Any]])
        XCTAssertTrue(writes.allSatisfy { $0["$type"] as? String == "com.atproto.repo.applyWrites#delete" })
        return writes.map { "\($0["collection"] as? String ?? "")/\($0["rkey"] as? String ?? "")" }
    }

    // MARK: - Tests

    /// The exif of the photos it alone places, its items, those photos, the
    /// account's favorites and comments on it, then the gallery: children
    /// first, in one commit.
    func testDeleteRemovesTheGalleryAndWhatGoesWithItInOneCommit() async throws {
        let client = makeClient()

        try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())

        XCTAssertEqual(try deleted(), [
            "social.grain.photo.exif/e1",
            "social.grain.gallery.item/i1",
            "social.grain.gallery.item/i2",
            "social.grain.photo/p1",
            "social.grain.favorite/f1",
            "social.grain.comment/c1",
            "social.grain.gallery/g1",
        ])
        XCTAssertFalse(
            recorder.paths.contains { $0.hasSuffix("com.atproto.repo.deleteRecord") },
            "deleteRecord orphans the gallery's photos, items, exif, and comments"
        )
    }

    /// A photo another gallery also places stays, with its exif.
    func testAPhotoAnotherGalleryPlacesIsKept() async throws {
        let client = makeClient()

        try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())

        let going = try deleted()
        XCTAssertFalse(going.contains("social.grain.photo/p2"))
        XCTAssertFalse(going.contains("social.grain.photo.exif/e2"))
    }

    /// The deletes are then indexed, so the gallery leaves the feeds at once.
    func testTheDeletesAreIndexed() async throws {
        let client = makeClient()

        try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())

        XCTAssertTrue(recorder.paths.last?.hasSuffix("social.grain.sync.indexRecords") ?? false)
    }

    /// Callers use `try?` and drop the error, so the failure has to at least
    /// reach them rather than being swallowed inside the service.
    func testDeletePropagatesAServerFailure() async {
        let client = makeClient(status: 500)

        do {
            try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())
            XCTFail("expected the failure to propagate")
        } catch {
            // expected
        }
    }
}

/// Records what the mock server was asked for. The handler runs off the main
/// actor, so it needs its own lock.
private final class Recorder: @unchecked Sendable {
    private let lock = NSLock()
    private var storedPaths: [String] = []
    private var storedBodies: [Data] = []
    private var bodyIndices: [Int?] = []

    func record(path: String, body: Data?) {
        lock.lock()
        defer { lock.unlock() }
        storedPaths.append(path)
        if let body {
            bodyIndices.append(storedBodies.count)
            storedBodies.append(body)
        } else {
            bodyIndices.append(nil)
        }
    }

    /// Where in `bodies` the body of the request at `index` is.
    func bodyIndex(ofPath index: Int) -> Int {
        lock.lock()
        defer { lock.unlock() }
        return bodyIndices[index] ?? 0
    }

    var paths: [String] {
        lock.lock()
        defer { lock.unlock() }
        return storedPaths
    }

    var bodies: [Data] {
        lock.lock()
        defer { lock.unlock() }
        return storedBodies
    }
}

private extension GalleryServiceTests {
    /// `URLProtocol` moves the body into a stream before the handler sees it.
    static func body(of request: URLRequest) -> Data? {
        if let body = request.httpBody {
            return body
        }
        guard let stream = request.httpBodyStream else { return nil }
        stream.open()
        defer { stream.close() }
        var data = Data()
        let bufferSize = 4096
        let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
        defer { buffer.deallocate() }
        while stream.hasBytesAvailable {
            let read = stream.read(buffer, maxLength: bufferSize)
            if read <= 0 {
                break
            }
            data.append(buffer, count: read)
        }
        return data
    }
}