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

grain.social/grain-ios

1 collaborator
0
1
main
254 lines · 11.6 KB Raw History
@testable import Grain
import XCTest

@MainActor
final class ViewedStoryStorageTests: GrainTestCase {
    private var storage: ViewedStoryStorage!
    /// Watch history is stored per account, and the test host shares
    /// UserDefaults with the app on the simulator — so pin these to a DID
    /// nobody is signed in as.
    private let testDID = "did:plc:viewedstoragetests"

    override func setUp() {
        super.setUp()
        AccountScopedStorage.purge(did: testDID)
        storage = ViewedStoryStorage(did: testDID)
    }

    override func tearDown() {
        AccountScopedStorage.purge(did: testDID)
        storage = nil
        super.tearDown()
    }

    // MARK: - Helpers

    private struct StubStory: StoryIdentifiable {
        let storyUri: String
    }

    private func makeStories(_ uris: [String]) -> [StubStory] {
        uris.map { StubStory(storyUri: $0) }
    }

    // MARK: - markViewed / isViewed

    func testMarkViewedTracksUri() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        XCTAssertTrue(storage.isViewed(uri: "at://story/1"))
        XCTAssertFalse(storage.isViewed(uri: "at://story/2"))
    }

    func testMarkViewedMultipleStories() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z")
        XCTAssertTrue(storage.isViewed(uri: "at://story/1"))
        XCTAssertTrue(storage.isViewed(uri: "at://story/2"))
    }

    // MARK: - hasViewedAll

    func testHasViewedAllReturnsTrueWhenLatestViewed() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z")
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testHasViewedAllReturnsTrueWhenViewedNewer() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T15:00:00.000Z")
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testHasViewedAllReturnsFalseWhenNotViewed() {
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testHasViewedAllReturnsFalseWhenOlderViewed() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testHasViewedAllTracksMostRecentTimestamp() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z")
        // Viewing an older story doesn't regress the timestamp
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testHasViewedAllIndependentPerAuthor() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z")
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:bob", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    // MARK: - firstUnviewedIndex

    func testFirstUnviewedIndexReturnsFirstUnviewed() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z")
        let stories = makeStories(["at://story/1", "at://story/2", "at://story/3", "at://story/4"])
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 2)
    }

    func testFirstUnviewedIndexReturnsZeroWhenNoneViewed() {
        let stories = makeStories(["at://story/1", "at://story/2", "at://story/3"])
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)
    }

    func testFirstUnviewedIndexReturnsZeroWhenAllViewed() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z")
        let stories = makeStories(["at://story/1", "at://story/2"])
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)
    }

    func testFirstUnviewedIndexSkipsViewedInMiddle() {
        // Only the middle story is viewed — should return index 0
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        let stories = makeStories(["at://story/1", "at://story/2", "at://story/3"])
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)
    }

    func testFirstUnviewedIndexWithSingleStory() {
        let stories = makeStories(["at://story/1"])
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)

        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)
    }

    func testFirstUnviewedIndexEmptyArray() {
        let stories: [StubStory] = []
        XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0)
    }

    // MARK: - Server state

    private func author(_ did: String, latestAt: String, lastViewedAt: String? = nil) -> GrainStoryAuthor {
        GrainStoryAuthor(
            profile: GrainProfile(cid: "", did: did, handle: "\(did).test"),
            storyCount: 1,
            latestAt: latestAt,
            lastViewedAt: lastViewedAt
        )
    }

    private func story(_ uri: String, by did: String, createdAt: String, viewed: Bool?) -> GrainStory {
        GrainStory(
            uri: uri,
            cid: "cid",
            creator: GrainProfile(cid: "", did: did, handle: "\(did).test"),
            thumb: "",
            fullsize: "",
            aspectRatio: AspectRatio(width: 9, height: 16),
            createdAt: createdAt,
            viewer: viewed.map { StoryViewerState(fav: nil, viewed: $0) }
        )
    }

    func testAbsorbingAuthorsMovesTheMarkForward() {
        storage.absorb(authors: [author("did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z", lastViewedAt: "2024-06-15T14:00:00.000Z")])
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testAbsorbingAuthorsNeverMovesTheMarkBack() {
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z")
        storage.absorb(authors: [author("did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z", lastViewedAt: "2024-06-15T12:00:00.000Z")])
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
    }

    func testAbsorbingAuthorsWithoutAMarkChangesNothing() {
        storage.absorb(authors: [author("did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")])
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z"))
        XCTAssertEqual(storage.pendingUploadCount, 0)
    }

    func testAbsorbingStoriesMarksTheFlaggedOnes() {
        storage.absorb(stories: [
            story("at://story/1", by: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z", viewed: true),
            story("at://story/2", by: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z", viewed: nil),
        ])
        XCTAssertTrue(storage.isViewed(uri: "at://story/1"))
        XCTAssertFalse(storage.isViewed(uri: "at://story/2"))
        XCTAssertEqual(storage.firstUnviewedIndex(in: makeStories(["at://story/1", "at://story/2"])), 1)
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T13:00:00.000Z"))
        // Nothing the server told us about goes back up to it.
        XCTAssertEqual(storage.pendingUploadCount, 0)
    }

    // MARK: - Reporting to the appview

    func testWatchedStoriesAreReportedInOneBatch() async {
        var sent: [[String]] = []
        storage.uploader = { uris in sent.append(uris.sorted()) }
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z")
        XCTAssertEqual(storage.pendingUploadCount, 2)

        await storage.flushPending()

        XCTAssertEqual(sent, [["at://story/1", "at://story/2"]])
        XCTAssertEqual(storage.pendingUploadCount, 0)
    }

    func testAFailedReportStaysQueued() async {
        struct Offline: Error {}
        storage.uploader = { _ in throw Offline() }
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")

        await storage.flushPending()

        XCTAssertEqual(storage.pendingUploadCount, 1)
        XCTAssertTrue(storage.isViewed(uri: "at://story/1"), "Locally it is still watched")

        var sent: [String] = []
        storage.uploader = { uris in sent = uris }
        await storage.flushPending()
        XCTAssertEqual(sent, ["at://story/1"])
        XCTAssertEqual(storage.pendingUploadCount, 0)
    }

    func testTheQueueSurvivesARelaunch() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        storage.cleanup() // saves synchronously
        let relaunched = ViewedStoryStorage(did: testDID)
        XCTAssertEqual(relaunched.pendingUploadCount, 1)
    }

    func testReportsGoUpInServerSizedBatches() async {
        var batches: [Int] = []
        storage.uploader = { uris in batches.append(uris.count) }
        for i in 0 ..< 150 {
            storage.markViewed(uri: "at://story/\(i)", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z")
        }
        await storage.flushPending()
        XCTAssertEqual(batches, [100, 50])
        XCTAssertEqual(storage.pendingUploadCount, 0)
    }

    // MARK: - cleanup

    func testCleanupRemovesOldAuthorEntries() {
        storage.markViewed(uri: "at://story/old", authorDid: "did:plc:old", createdAt: "2020-01-01T12:00:00.000Z")
        storage.markViewed(uri: "at://story/new", authorDid: "did:plc:new", createdAt: "2099-01-01T12:00:00.000Z")
        storage.cleanup()
        XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:old", latestAt: "2020-01-01T12:00:00.000Z"))
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:new", latestAt: "2099-01-01T12:00:00.000Z"))
    }

    func testCleanupPreservesRecentAuthorEntries() {
        storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2099-06-15T12:00:00.000Z")
        storage.cleanup()
        XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2099-06-15T12:00:00.000Z"))
    }

    func testCleanupCapsViewedUrisWhenOver500() {
        for i in 0 ..< 600 {
            storage.markViewed(uri: "at://story/\(i)", authorDid: "did:plc:alice", createdAt: "2099-01-01T12:00:00.000Z")
        }
        storage.cleanup()
        // After cleanup, total viewed URIs should be capped — at least some should no longer be tracked
        let stillViewedCount = (0 ..< 600).count(where: { storage.isViewed(uri: "at://story/\($0)") })
        XCTAssertLessThanOrEqual(stillViewedCount, 200)
    }
}