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}