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

grain.social/grain-ios

1 collaborator
0
1
main
128 lines · 5.2 KB Raw History
@testable import Grain
import XCTest

/// The cache is read during every card's body evaluation to decide whether a
/// gallery is blurred, so it has to resolve labels before anything loads,
/// fetch grain-serve's definitions once, and survive a failure.
@MainActor
final class LabelDefinitionsCacheTests: GrainTestCase {
    private var client: XRPCClient!

    override func setUp() async throws {
        try await super.setUp()
        client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
    }

    override func tearDown() async throws {
        MockURLProtocol.handler = nil
        try await super.tearDown()
    }

    /// `social.grain.moderation.describeLabels`, as grain-serve answers it.
    private static let described = """
    {"did": "did:web:grain.social", "policies": {
      "labelValues": ["!takedown", "spoiler", "nudity"],
      "labelValueDefinitions": [
        {"identifier": "spoiler", "severity": "inform", "blurs": "content", "defaultSetting": "warn",
         "locales": [{"lang": "en", "name": "Spoiler", "description": "Plot spoiler"}]},
        {"identifier": "nudity", "severity": "inform", "blurs": "media", "defaultSetting": "hide",
         "locales": [{"lang": "en", "name": "Nudity", "description": "Content containing nudity."}]}
      ]
    }}
    """

    func testItStartsOnTheCopyTheAppCarries() {
        let cache = LabelDefinitionsCache()

        XCTAssertEqual(
            cache.definitions.map(\.identifier),
            ["copyright", "csae", "gore", "harassment", "misleading", "nudity", "other", "sexual", "spam"]
        )
    }

    func testLoadingReadsGrainServesDefinitions() async {
        let seen = Paths()
        MockURLProtocol.handler = { request in
            seen.append(request.url?.path ?? "")
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(Self.described.utf8), response)
        }
        let cache = LabelDefinitionsCache()

        await cache.loadIfNeeded(client: client, auth: nil)

        XCTAssertEqual(seen.all, ["/xrpc/social.grain.moderation.describeLabels"])
        XCTAssertEqual(cache.definitions.map(\.identifier), ["spoiler", "nudity"])
        XCTAssertEqual(cache.definitions.first?.displayName, "Spoiler")
    }

    /// What was read stands on the next launch, before anything loads.
    func testTheDefinitionsReadAreKeptForTheNextLaunch() async {
        MockURLProtocol.respondWithJSON(Self.described)
        await LabelDefinitionsCache().loadIfNeeded(client: client, auth: nil)

        XCTAssertEqual(LabelDefinitionsCache().definitions.map(\.identifier), ["spoiler", "nudity"])
    }

    /// Every screen with labels calls this, so it fetches once.
    func testItOnlyFetchesOnce() async {
        let seen = Paths()
        MockURLProtocol.handler = { request in
            seen.append(request.url?.path ?? "")
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(Self.described.utf8), response)
        }
        let cache = LabelDefinitionsCache()

        await cache.loadIfNeeded(client: client, auth: nil)
        await cache.loadIfNeeded(client: client, auth: nil)

        XCTAssertEqual(seen.all.count, 1)
    }

    /// Offline, the copy the app carries stands, and the next call asks again.
    func testAFailedLoadKeepsTheCopyAndIsRetried() async {
        MockURLProtocol.respondWithError(statusCode: 500)
        let cache = LabelDefinitionsCache()
        await cache.loadIfNeeded(client: client, auth: nil)
        XCTAssertEqual(cache.definitions, LabelDefinition.all)

        MockURLProtocol.respondWithJSON(Self.described)
        await cache.loadIfNeeded(client: client, auth: nil)
        XCTAssertEqual(cache.definitions.map(\.identifier), ["spoiler", "nudity"])
    }

    /// A definition beats the built-in fallback for a value both know.
    func testADefinitionOverridesTheFallbackTable() {
        let cache = LabelDefinitionsCache(definitions: [
            LabelDefinition(identifier: "nudity", blurs: "content", defaultSetting: "warn"),
        ])

        let resolved = resolveLabels([ATLabel(src: nil, uri: nil, val: "nudity", cts: nil)], definitions: cache.definitions)
        XCTAssertEqual(resolved.action, .warnContent, "Fallback would have given warnMedia")
    }

    /// Every label has the reason a report for it names, the one grain-serve
    /// files under that label.
    func testEveryLabelHasAReportReason() {
        for definition in LabelDefinition.all {
            XCTAssertNotNil(LabelDefinition.reportReasons[definition.identifier], definition.identifier)
        }
        XCTAssertEqual(LabelDefinition.reasonType(for: "harassment"), "com.atproto.moderation.defs#reasonRude")
        XCTAssertEqual(LabelDefinition.reasonType(for: "unheard-of"), "com.atproto.moderation.defs#reasonOther")
    }
}

/// The paths the mock was asked for, from outside the main actor.
private final class Paths: @unchecked Sendable {
    private let lock = NSLock()
    private var paths: [String] = []

    func append(_ path: String) {
        lock.withLock { paths.append(path) }
    }

    var all: [String] {
        lock.withLock { paths }
    }
}