krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

1.1: octosentryTests/PersistedStateTests.swift · raw

  1//
  2//  PersistedStateTests.swift
  3//  octosentryTests
  4//
  5//  PersistedState is a file format: state.json written by one version has
  6//  to load in the next. These pin the current shape.
  7//
  8
  9import Foundation
 10import Testing
 11@testable import octosentry
 12
 13struct PersistedStateTests {
 14
 15    private static let encoder: JSONEncoder = {
 16        let encoder = JSONEncoder()
 17        encoder.dateEncodingStrategy = .iso8601
 18        return encoder
 19    }()
 20
 21    private static let decoder: JSONDecoder = {
 22        let decoder = JSONDecoder()
 23        decoder.dateDecodingStrategy = .iso8601
 24        return decoder
 25    }()
 26
 27    @Test func roundTripsEveryField() throws {
 28        let fetchedAt = Date(timeIntervalSince1970: 1_785_000_000)
 29        let original = PersistedState(
 30            accounts: [Account.new(id: 42, login: "octocat", hasRepoScope: true)],
 31            watchedRepos: [
 32                WatchedRepo(fullName: "octocat/hello-world", accountID: 42),
 33                WatchedRepo(fullName: "octocat/spoon-knife", accountID: 42),
 34            ],
 35            seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"],
 36            lastFetchByRepo: ["octocat/hello-world": fetchedAt],
 37            minimumSeverity: .high,
 38            hasRepoScope: true,
 39            sortOrder: .repo,
 40            notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]],
 41            triage: {
 42                var triage = AlertTriage()
 43                triage.dismiss("dependabot-octocat/hello-world-2")
 44                triage.snooze("codeScanning-octocat/spoon-knife-7", until: Date(timeIntervalSince1970: 1_786_000_000))
 45                return triage
 46            }()
 47        )
 48
 49        let decoded = try Self.decoder.decode(
 50            PersistedState.self,
 51            from: Self.encoder.encode(original)
 52        )
 53
 54        #expect(decoded.watchedRepos == original.watchedRepos)
 55        #expect(decoded.accounts == original.accounts)
 56        #expect(decoded.seenEventIDs == original.seenEventIDs)
 57        #expect(decoded.lastFetchByRepo == original.lastFetchByRepo)
 58        #expect(decoded.minimumSeverity == original.minimumSeverity)
 59        #expect(decoded.hasRepoScope == original.hasRepoScope)
 60        #expect(decoded.sortOrder == original.sortOrder)
 61        #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo)
 62        #expect(decoded.triage == original.triage)
 63    }
 64
 65    @Test func encodesTheKeysOnDiskReadersDependOn() throws {
 66        let data = try Self.encoder.encode(PersistedState.placeholder)
 67        let object = try #require(
 68            try JSONSerialization.jsonObject(with: data) as? [String: Any]
 69        )
 70
 71        #expect(Set(object.keys) == [
 72            "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
 73            "triage", "history", "accounts",
 74        ])
 75        // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
 76        // encodes to nothing rather than a null.
 77        #expect(object["notifiedEventIDsByRepo"] == nil)
 78    }
 79
 80    // A state.json written before hasRepoScope and sortOrder existed must still load.
 81    @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws {
 82        let legacy = """
 83        {
 84          "watchedRepos": ["octocat/hello-world"],
 85          "seenEventIDs": ["dependabot-octocat/hello-world-1"],
 86          "lastFetchByRepo": {"octocat/hello-world": "2026-08-01T12:00:00Z"},
 87          "minimumSeverity": 1
 88        }
 89        """
 90
 91        let state = try Self.decoder.decode(PersistedState.self, from: Data(legacy.utf8))
 92
 93        // The pre-multi-account shape was a plain [String].
 94        #expect(state.watchedRepos == [WatchedRepo(fullName: "octocat/hello-world", accountID: 0)])
 95        #expect(state.accounts.isEmpty)
 96        #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"])
 97        #expect(state.minimumSeverity == .medium)
 98        #expect(state.hasRepoScope == false)
 99        #expect(state.sortOrder == .severity)
100        #expect(state.notifiedEventIDsByRepo == nil)
101        #expect(state.triage == AlertTriage())
102        #expect(state.history == AlertHistory())
103    }
104
105    @Test func rejectsStateMissingARequiredField() {
106        let missingWatchList = """
107        {"seenEventIDs": [], "lastFetchByRepo": {}, "minimumSeverity": 0}
108        """
109
110        #expect(throws: (any Error).self) {
111            try Self.decoder.decode(PersistedState.self, from: Data(missingWatchList.utf8))
112        }
113    }
114
115    @Test func placeholderStartsWithNoSeenStateAndNoRepoScope() {
116        #expect(PersistedState.placeholder.seenEventIDs.isEmpty)
117        #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty)
118        #expect(PersistedState.placeholder.minimumSeverity == .low)
119        #expect(PersistedState.placeholder.hasRepoScope == false)
120        #expect(PersistedState.placeholder.sortOrder == .severity)
121    }
122}