Commit a7d154e1db

a7d154e1db9fd9904b2255db889144ad15aac2f2

parent: 22ad96331a

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 00:27 UTC

test: establish unit-test net for the deterministic core (v5 step 1)

Stands up the DomainDigTests unit-test target the v5.0.0 roadmap flags as the
mandatory first move before decomposing the god-files and locking external
contracts. The project had no XCTest unit target — only the DomainDigUITests
accessibility suite.

- New DomainDigTests target (unit_test_bundle), hosted by the app so
  @testable import DomainDig links. Mirrors the UITests build settings
  (SWIFT_VERSION 5.0 + MainActor default isolation) to avoid the XCTest
  override-isolation issue recorded for the Swift 6 targets. Wired into the
  shared DomainDig scheme's Test action, so it runs in CI and the pre-push
  audit hook automatically (both invoke the whole scheme).

- SnapshotFixture builds the deep LookupSnapshot/DomainReport models through
  their real initializer and the DomainReportBuilder, exposing only the fields
  the tests vary.

- 36 characterization tests across the four deterministic units the roadmap
  names:
  - DiffService: change classification, case/whitespace normalization, DNS
    record reorder-vs-change, summary phrasing, resolver context note,
    certificate-warning thresholds.
  - DomainReportBuilder: snapshot -> report field mapping, primary-IP and
    DNSSEC derivation, TLS status, partial-snapshot/validation passthrough.
  - DomainReportExporter: format dispatch, JSON round-trip (the machine
    contract), CSV/markdown/text/PDF structural invariants, timeline.
  - DomainDataPortabilityService: CSV round-trip and the merge/dedup semantics
    (case-insensitive collapse, OR-merged pin state, min-created/max-updated,
    recency sort) via an ephemeral UserDefaults suite.

- build.yml comment updated to reflect the second test target.

Layout: unified · split

.github/workflows/build.yml +4 −2
@@ -6,8 +6,10 @@ name: Build
6# 6#
7# This runs `xcodebuild test`, which also compiles the app, the widget, and the 7# This runs `xcodebuild test`, which also compiles the app, the widget, and the
8# share extension (the DomainDig scheme's build action pulls both in as 8# share extension (the DomainDig scheme's build action pulls both in as
9# dependencies). The test target is DomainDigUITests — an accessibility audit 9# dependencies). The scheme has two test targets: DomainDigUITests — an
10# suite; see DomainDigUITests/AccessibilityAuditHarness.swift. 10# accessibility audit suite; see DomainDigUITests/AccessibilityAuditHarness.swift
11# — and DomainDigTests — unit coverage of the deterministic core (report builder,
12# exporter, diff, portability dedup).
11# 13#
12# WHAT THIS JOB IS FOR, given the audit also runs locally: 14# WHAT THIS JOB IS FOR, given the audit also runs locally:
13# a clean checkout of the merge result. A local hook runs against the working 15# a clean checkout of the merge result. A local hook runs against the working
DomainDig.xcodeproj/project.pbxproj +171 −8
@@ -7,6 +7,9 @@
7 objects = { 7 objects = {
8 8
9/* Begin PBXBuildFile section */ 9/* Begin PBXBuildFile section */
10 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; };
11 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
12 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
10 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; 13 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
11 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 14 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
12 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 15 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
@@ -16,9 +19,19 @@
16 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 19 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
17 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 20 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
18 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 21 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
22 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; };
23 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; };
24 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; };
19/* End PBXBuildFile section */ 25/* End PBXBuildFile section */
20 26
21/* Begin PBXContainerItemProxy section */ 27/* Begin PBXContainerItemProxy section */
28 57D61F819B6EA1C654237C8F /* PBXContainerItemProxy */ = {
29 isa = PBXContainerItemProxy;
30 containerPortal = 8B7800612F6090E300933221 /* Project object */;
31 proxyType = 1;
32 remoteGlobalIDString = 8B7800682F6090E300933221;
33 remoteInfo = DomainDig;
34 };
22 8BDA00010000000000000003 /* PBXContainerItemProxy */ = { 35 8BDA00010000000000000003 /* PBXContainerItemProxy */ = {
23 isa = PBXContainerItemProxy; 36 isa = PBXContainerItemProxy;
24 containerPortal = 8B7800612F6090E300933221 /* Project object */; 37 containerPortal = 8B7800612F6090E300933221 /* Project object */;
@@ -58,6 +71,9 @@
58/* End PBXCopyFilesBuildPhase section */ 71/* End PBXCopyFilesBuildPhase section */
59 72
60/* Begin PBXFileReference section */ 73/* Begin PBXFileReference section */
74 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; };
75 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; };
76 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; };
61 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; 77 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
62 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 78 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
63 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 79 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
@@ -70,6 +86,10 @@
70 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 86 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
71 8BDC00010000000000000001 /* DomainDigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DomainDigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 87 8BDC00010000000000000001 /* DomainDigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DomainDigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
72 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; 88 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
89 A77F7ED29E7C6669F5E61C04 /* DomainDigTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DomainDigTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
90 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; };
91 DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
92 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; };
73/* End PBXFileReference section */ 93/* End PBXFileReference section */
74 94
75/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 95/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -109,6 +129,8 @@
109 }; 129 };
110 8BDA0001000000000000000D /* Shared */ = { 130 8BDA0001000000000000000D /* Shared */ = {
111 isa = PBXFileSystemSynchronizedRootGroup; 131 isa = PBXFileSystemSynchronizedRootGroup;
132 exceptions = (
133 );
112 path = Shared; 134 path = Shared;
113 sourceTree = "<group>"; 135 sourceTree = "<group>";
114 }; 136 };
@@ -130,12 +152,22 @@
130 }; 152 };
131 8BDC0001000000000000000E /* DomainDigUITests */ = { 153 8BDC0001000000000000000E /* DomainDigUITests */ = {
132 isa = PBXFileSystemSynchronizedRootGroup; 154 isa = PBXFileSystemSynchronizedRootGroup;
155 exceptions = (
156 );
133 path = DomainDigUITests; 157 path = DomainDigUITests;
134 sourceTree = "<group>"; 158 sourceTree = "<group>";
135 }; 159 };
136/* End PBXFileSystemSynchronizedRootGroup section */ 160/* End PBXFileSystemSynchronizedRootGroup section */
137 161
138/* Begin PBXFrameworksBuildPhase section */ 162/* Begin PBXFrameworksBuildPhase section */
163 68EB2A07F42245EE1B09B018 /* Frameworks */ = {
164 isa = PBXFrameworksBuildPhase;
165 buildActionMask = 2147483647;
166 files = (
167 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */,
168 );
169 runOnlyForDeploymentPostprocessing = 0;
170 };
139 8B7800662F6090E300933221 /* Frameworks */ = { 171 8B7800662F6090E300933221 /* Frameworks */ = {
140 isa = PBXFrameworksBuildPhase; 172 isa = PBXFrameworksBuildPhase;
141 buildActionMask = 2147483647; 173 buildActionMask = 2147483647;
@@ -167,6 +199,36 @@
167/* End PBXFrameworksBuildPhase section */ 199/* End PBXFrameworksBuildPhase section */
168 200
169/* Begin PBXGroup section */ 201/* Begin PBXGroup section */
202 12026CD045DFB45E9E37D207 /* Frameworks */ = {
203 isa = PBXGroup;
204 children = (
205 E4CAAB98268DE269071F04E3 /* iOS */,
206 );
207 name = Frameworks;
208 sourceTree = "<group>";
209 };
210 2B31A5F9F390F512E966D4B1 /* Fixtures */ = {
211 isa = PBXGroup;
212 children = (
213 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */,
214 );
215 name = Fixtures;
216 path = Fixtures;
217 sourceTree = "<group>";
218 };
219 7CC29A88387286DE8B4D17B3 /* DomainDigTests */ = {
220 isa = PBXGroup;
221 children = (
222 2B31A5F9F390F512E966D4B1 /* Fixtures */,
223 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */,
224 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */,
225 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */,
226 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */,
227 );
228 name = DomainDigTests;
229 path = DomainDigTests;
230 sourceTree = "<group>";
231 };
170 8B7800602F6090E300933221 = { 232 8B7800602F6090E300933221 = {
171 isa = PBXGroup; 233 isa = PBXGroup;
172 children = ( 234 children = (
@@ -184,6 +246,8 @@
184 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */, 246 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
185 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */, 247 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */,
186 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, 248 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */,
249 12026CD045DFB45E9E37D207 /* Frameworks */,
250 7CC29A88387286DE8B4D17B3 /* DomainDigTests */,
187 ); 251 );
188 sourceTree = "<group>"; 252 sourceTree = "<group>";
189 }; 253 };
@@ -194,13 +258,40 @@
194 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, 258 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
195 8BDB00010000000000000001 /* DomainDigShareExtension.appex */, 259 8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
196 8BDC00010000000000000001 /* DomainDigUITests.xctest */, 260 8BDC00010000000000000001 /* DomainDigUITests.xctest */,
261 A77F7ED29E7C6669F5E61C04 /* DomainDigTests.xctest */,
197 ); 262 );
198 name = Products; 263 name = Products;
199 sourceTree = "<group>"; 264 sourceTree = "<group>";
200 }; 265 };
266 E4CAAB98268DE269071F04E3 /* iOS */ = {
267 isa = PBXGroup;
268 children = (
269 DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */,
270 );
271 name = iOS;
272 sourceTree = "<group>";
273 };
201/* End PBXGroup section */ 274/* End PBXGroup section */
202 275
203/* Begin PBXNativeTarget section */ 276/* Begin PBXNativeTarget section */
277 77CB8BAE320E7119E3283122 /* DomainDigTests */ = {
278 isa = PBXNativeTarget;
279 buildConfigurationList = 108E5397EABF84F5A56080A9 /* Build configuration list for PBXNativeTarget "DomainDigTests" */;
280 buildPhases = (
281 34C44EAF4B4DA8C10B33208D /* Sources */,
282 68EB2A07F42245EE1B09B018 /* Frameworks */,
283 A5B47E96AE4B6A13198132C2 /* Resources */,
284 );
285 buildRules = (
286 );
287 dependencies = (
288 D17881CF5E934733B8E99DE8 /* PBXTargetDependency */,
289 );
290 name = DomainDigTests;
291 productName = DomainDigTests;
292 productReference = A77F7ED29E7C6669F5E61C04 /* DomainDigTests.xctest */;
293 productType = "com.apple.product-type.bundle.unit-test";
294 };
204 8B7800682F6090E300933221 /* DomainDig */ = { 295 8B7800682F6090E300933221 /* DomainDig */ = {
205 isa = PBXNativeTarget; 296 isa = PBXNativeTarget;
206 buildConfigurationList = 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */; 297 buildConfigurationList = 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */;
@@ -221,8 +312,6 @@
221 8BDA0001000000000000000D /* Shared */, 312 8BDA0001000000000000000D /* Shared */,
222 ); 313 );
223 name = DomainDig; 314 name = DomainDig;
224 packageProductDependencies = (
225 );
226 productName = DomainDig; 315 productName = DomainDig;
227 productReference = 8B7800692F6090E300933221 /* DomainDig.app */; 316 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
228 productType = "com.apple.product-type.application"; 317 productType = "com.apple.product-type.application";
@@ -244,8 +333,6 @@
244 8BDA0001000000000000000E /* DomainDigWidget */, 333 8BDA0001000000000000000E /* DomainDigWidget */,
245 ); 334 );
246 name = DomainDigWidgetExtension; 335 name = DomainDigWidgetExtension;
247 packageProductDependencies = (
248 );
249 productName = DomainDigWidgetExtension; 336 productName = DomainDigWidgetExtension;
250 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; 337 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */;
251 productType = "com.apple.product-type.app-extension"; 338 productType = "com.apple.product-type.app-extension";
@@ -267,8 +354,6 @@
267 8BDB0001000000000000000E /* DomainDigShareExtension */, 354 8BDB0001000000000000000E /* DomainDigShareExtension */,
268 ); 355 );
269 name = DomainDigShareExtension; 356 name = DomainDigShareExtension;
270 packageProductDependencies = (
271 );
272 productName = DomainDigShareExtension; 357 productName = DomainDigShareExtension;
273 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; 358 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
274 productType = "com.apple.product-type.app-extension"; 359 productType = "com.apple.product-type.app-extension";
@@ -290,8 +375,6 @@
290 8BDC0001000000000000000E /* DomainDigUITests */, 375 8BDC0001000000000000000E /* DomainDigUITests */,
291 ); 376 );
292 name = DomainDigUITests; 377 name = DomainDigUITests;
293 packageProductDependencies = (
294 );
295 productName = DomainDigUITests; 378 productName = DomainDigUITests;
296 productReference = 8BDC00010000000000000001 /* DomainDigUITests.xctest */; 379 productReference = 8BDC00010000000000000001 /* DomainDigUITests.xctest */;
297 productType = "com.apple.product-type.bundle.ui-testing"; 380 productType = "com.apple.product-type.bundle.ui-testing";
@@ -344,6 +427,7 @@
344 8BDA00010000000000000006 /* DomainDigWidgetExtension */, 427 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
345 8BDB00010000000000000006 /* DomainDigShareExtension */, 428 8BDB00010000000000000006 /* DomainDigShareExtension */,
346 8BDC00010000000000000006 /* DomainDigUITests */, 429 8BDC00010000000000000006 /* DomainDigUITests */,
430 77CB8BAE320E7119E3283122 /* DomainDigTests */,
347 ); 431 );
348 }; 432 };
349/* End PBXProject section */ 433/* End PBXProject section */
@@ -377,9 +461,28 @@
377 ); 461 );
378 runOnlyForDeploymentPostprocessing = 0; 462 runOnlyForDeploymentPostprocessing = 0;
379 }; 463 };
464 A5B47E96AE4B6A13198132C2 /* Resources */ = {
465 isa = PBXResourcesBuildPhase;
466 buildActionMask = 2147483647;
467 files = (
468 );
469 runOnlyForDeploymentPostprocessing = 0;
470 };
380/* End PBXResourcesBuildPhase section */ 471/* End PBXResourcesBuildPhase section */
381 472
382/* Begin PBXSourcesBuildPhase section */ 473/* Begin PBXSourcesBuildPhase section */
474 34C44EAF4B4DA8C10B33208D /* Sources */ = {
475 isa = PBXSourcesBuildPhase;
476 buildActionMask = 2147483647;
477 files = (
478 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */,
479 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */,
480 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
481 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
482 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
483 );
484 runOnlyForDeploymentPostprocessing = 0;
485 };
383 8B7800652F6090E300933221 /* Sources */ = { 486 8B7800652F6090E300933221 /* Sources */ = {
384 isa = PBXSourcesBuildPhase; 487 isa = PBXSourcesBuildPhase;
385 buildActionMask = 2147483647; 488 buildActionMask = 2147483647;
@@ -433,9 +536,38 @@
433 target = 8B7800682F6090E300933221 /* DomainDig */; 536 target = 8B7800682F6090E300933221 /* DomainDig */;
434 targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */; 537 targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */;
435 }; 538 };
539 D17881CF5E934733B8E99DE8 /* PBXTargetDependency */ = {
540 isa = PBXTargetDependency;
541 name = DomainDig;
542 target = 8B7800682F6090E300933221 /* DomainDig */;
543 targetProxy = 57D61F819B6EA1C654237C8F /* PBXContainerItemProxy */;
544 };
436/* End PBXTargetDependency section */ 545/* End PBXTargetDependency section */
437 546
438/* Begin XCBuildConfiguration section */ 547/* Begin XCBuildConfiguration section */
548 87FD0A9B1630561EDC7F2A3D /* Release */ = {
549 isa = XCBuildConfiguration;
550 buildSettings = {
551 BUNDLE_LOADER = "$(TEST_HOST)";
552 CLANG_ENABLE_OBJC_WEAK = NO;
553 CODE_SIGN_STYLE = Automatic;
554 CURRENT_PROJECT_VERSION = 44;
555 DEVELOPMENT_TEAM = ZCNAX3VL9D;
556 GENERATE_INFOPLIST_FILE = YES;
557 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
558 MARKETING_VERSION = 4.9.0;
559 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
560 PRODUCT_NAME = "$(TARGET_NAME)";
561 SDKROOT = iphoneos;
562 SWIFT_APPROACHABLE_CONCURRENCY = YES;
563 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
564 SWIFT_VERSION = 5.0;
565 TARGETED_DEVICE_FAMILY = "1,2";
566 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DomainDig.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DomainDig";
567 VALIDATE_PRODUCT = YES;
568 };
569 name = Release;
570 };
439 8B7800722F6090E400933221 /* Debug */ = { 571 8B7800722F6090E400933221 /* Debug */ = {
440 isa = XCBuildConfiguration; 572 isa = XCBuildConfiguration;
441 buildSettings = { 573 buildSettings = {
@@ -787,9 +919,40 @@
787 }; 919 };
788 name = Release; 920 name = Release;
789 }; 921 };
922 A6C716A4AC78E316F1378CBB /* Debug */ = {
923 isa = XCBuildConfiguration;
924 buildSettings = {
925 BUNDLE_LOADER = "$(TEST_HOST)";
926 CLANG_ENABLE_OBJC_WEAK = NO;
927 CODE_SIGN_STYLE = Automatic;
928 CURRENT_PROJECT_VERSION = 44;
929 DEVELOPMENT_TEAM = ZCNAX3VL9D;
930 GENERATE_INFOPLIST_FILE = YES;
931 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
932 MARKETING_VERSION = 4.9.0;
933 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
934 PRODUCT_NAME = "$(TARGET_NAME)";
935 SDKROOT = iphoneos;
936 SWIFT_APPROACHABLE_CONCURRENCY = YES;
937 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
938 SWIFT_VERSION = 5.0;
939 TARGETED_DEVICE_FAMILY = "1,2";
940 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DomainDig.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DomainDig";
941 };
942 name = Debug;
943 };
790/* End XCBuildConfiguration section */ 944/* End XCBuildConfiguration section */
791 945
792/* Begin XCConfigurationList section */ 946/* Begin XCConfigurationList section */
947 108E5397EABF84F5A56080A9 /* Build configuration list for PBXNativeTarget "DomainDigTests" */ = {
948 isa = XCConfigurationList;
949 buildConfigurations = (
950 87FD0A9B1630561EDC7F2A3D /* Release */,
951 A6C716A4AC78E316F1378CBB /* Debug */,
952 );
953 defaultConfigurationIsVisible = 0;
954 defaultConfigurationName = Release;
955 };
793 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */ = { 956 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */ = {
794 isa = XCConfigurationList; 957 isa = XCConfigurationList;
795 buildConfigurations = ( 958 buildConfigurations = (
DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme +10
@@ -40,6 +40,16 @@
40 ReferencedContainer = "container:DomainDig.xcodeproj"> 40 ReferencedContainer = "container:DomainDig.xcodeproj">
41 </BuildableReference> 41 </BuildableReference>
42 </TestableReference> 42 </TestableReference>
43 <TestableReference
44 skipped = "NO">
45 <BuildableReference
46 BuildableIdentifier = "primary"
47 BlueprintIdentifier = "77CB8BAE320E7119E3283122"
48 BuildableName = "DomainDigTests.xctest"
49 BlueprintName = "DomainDigTests"
50 ReferencedContainer = "container:DomainDig.xcodeproj">
51 </BuildableReference>
52 </TestableReference>
43 </Testables> 53 </Testables>
44 </TestAction> 54 </TestAction>
45 <LaunchAction 55 <LaunchAction
DomainDigTests/DiffServiceTests.swift added +163
@@ -0,0 +1,163 @@
1import XCTest
2@testable import DomainDig
3
4/// Characterization tests for the field-level diff engine. `DiffService` is a
5/// pure function over two `DomainReport`s, so every case here pins observable
6/// behavior (change classification, normalization, summary phrasing) against a
7/// deterministic fixture.
8final class DiffServiceTests: XCTestCase {
9
10 func testIdenticalReportsProduceNoChanges() {
11 let report = SnapshotFixture.report(
12 availability: .registered,
13 ownership: DomainOwnership(registrar: "Example Registrar")
14 )
15
16 let diff = DiffService.compare(from: report, to: report)
17
18 XCTAssertEqual(diff.changeCount, 0)
19 XCTAssertTrue(diff.changedSectionTitles.isEmpty)
20 XCTAssertFalse(diff.sections.contains { $0.hasChanges })
21 }
22
23 func testAvailabilityTransitionIsReportedAsChanged() {
24 let old = SnapshotFixture.report(availability: .available)
25 let new = SnapshotFixture.report(availability: .registered)
26
27 let diff = DiffService.compare(from: old, to: new)
28
29 let availability = try? XCTUnwrap(diff.sections.first { $0.id == "availability" })
30 let item = availability?.items.first { $0.id == "availability" }
31 XCTAssertEqual(item?.changeType, .changed)
32 XCTAssertEqual(item?.oldValue, "Available")
33 XCTAssertEqual(item?.newValue, "Registered")
34 XCTAssertTrue(diff.changedSectionTitles.contains("Domain / Availability"))
35 }
36
37 func testPrimaryIPChangeSurfacesInAvailabilitySection() {
38 let old = SnapshotFixture.report(
39 availability: .registered,
40 dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.10"])]
41 )
42 let new = SnapshotFixture.report(
43 availability: .registered,
44 dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.20"])]
45 )
46
47 let diff = DiffService.compare(from: old, to: new)
48 let item = diff.sections
49 .first { $0.id == "availability" }?
50 .items.first { $0.id == "primary-ip" }
51
52 XCTAssertEqual(item?.changeType, .changed)
53 XCTAssertEqual(item?.oldValue, "203.0.113.10")
54 XCTAssertEqual(item?.newValue, "203.0.113.20")
55 }
56
57 func testCaseAndWhitespaceDifferencesAreNotChanges() {
58 let old = SnapshotFixture.report(ownership: DomainOwnership(registrar: "GoDaddy"))
59 let new = SnapshotFixture.report(ownership: DomainOwnership(registrar: " godaddy "))
60
61 let diff = DiffService.compare(from: old, to: new)
62 let item = diff.sections
63 .first { $0.id == "ownership" }?
64 .items.first { $0.id == "registrar" }
65
66 XCTAssertEqual(item?.changeType, .unchanged, "case- and whitespace-only differences must not register as changes")
67 }
68
69 func testAddedAndRemovedOwnershipFields() {
70 let absent = SnapshotFixture.report(ownership: nil)
71 let present = SnapshotFixture.report(ownership: DomainOwnership(registrar: "Example Registrar"))
72
73 let added = DiffService.compare(from: absent, to: present)
74 .sections.first { $0.id == "ownership" }?
75 .items.first { $0.id == "registrar" }
76 XCTAssertEqual(added?.changeType, .added)
77 XCTAssertNil(added?.oldValue)
78 XCTAssertEqual(added?.newValue, "Example Registrar")
79
80 let removed = DiffService.compare(from: present, to: absent)
81 .sections.first { $0.id == "ownership" }?
82 .items.first { $0.id == "registrar" }
83 XCTAssertEqual(removed?.changeType, .removed)
84 XCTAssertEqual(removed?.oldValue, "Example Registrar")
85 XCTAssertNil(removed?.newValue)
86 }
87
88 func testDNSRecordValueChangeIsDetected() {
89 let old = SnapshotFixture.report(
90 dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns2.example.com"])]
91 )
92 let new = SnapshotFixture.report(
93 dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns3.example.com"])]
94 )
95
96 let dns = DiffService.compare(from: old, to: new).sections.first { $0.id == "dns" }
97 let records = dns?.items.first { $0.id == "dns-ns-records" }
98 XCTAssertEqual(records?.changeType, .changed)
99 }
100
101 func testDNSRecordReorderIsNotAChange() {
102 // Values are normalized (sorted, lowercased) before comparison, so a pure
103 // reorder must diff as unchanged.
104 let old = SnapshotFixture.report(
105 dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns2.example.com"])]
106 )
107 let new = SnapshotFixture.report(
108 dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["NS2.example.com", "NS1.example.com"])]
109 )
110
111 let records = DiffService.compare(from: old, to: new)
112 .sections.first { $0.id == "dns" }?
113 .items.first { $0.id == "dns-ns-records" }
114 XCTAssertEqual(records?.changeType, .unchanged)
115 }
116
117 func testSummaryMessagePhrasing() {
118 XCTAssertEqual(DiffService.summaryMessage(from: [], changeCount: 0), "No meaningful changes")
119 XCTAssertEqual(DiffService.summaryMessage(from: ["DNS"], changeCount: 1), "DNS changed")
120 XCTAssertEqual(
121 DiffService.summaryMessage(from: ["DNS", "Ownership"], changeCount: 3),
122 "DNS and ownership changed (3 items)"
123 )
124 }
125
126 func testContextNoteFlagsDifferentResolvers() {
127 let old = SnapshotFixture.report(resolverURLString: "https://one.example/dns-query")
128 let new = SnapshotFixture.report(resolverURLString: "https://two.example/dns-query")
129
130 let note = DiffService.comparisonContextNote(from: old, to: new)
131 XCTAssertEqual(note, "Compared snapshots used different DNS resolvers.")
132 }
133
134 func testContextNoteNilWhenResolversMatch() {
135 let report = SnapshotFixture.report()
136 XCTAssertNil(DiffService.comparisonContextNote(from: report, to: report))
137 }
138
139 func testCertificateWarningLevelThresholds() {
140 func level(daysUntilExpiry days: Int?) -> CertificateWarningLevel {
141 let ssl = days.map { SnapshotFixture.certificate(daysUntilExpiry: $0) }
142 return DiffService.certificateWarningLevel(for: SnapshotFixture.snapshot(sslInfo: ssl))
143 }
144
145 XCTAssertEqual(level(daysUntilExpiry: nil), .none)
146 XCTAssertEqual(level(daysUntilExpiry: 45), .none)
147 XCTAssertEqual(level(daysUntilExpiry: 29), .warning)
148 XCTAssertEqual(level(daysUntilExpiry: 14), .warning)
149 XCTAssertEqual(level(daysUntilExpiry: 13), .critical)
150 XCTAssertEqual(level(daysUntilExpiry: 0), .critical)
151 }
152
153 func testCrossDomainComparisonPairsBothDomains() {
154 let a = SnapshotFixture.report(domain: "alpha.example", availability: .registered)
155 let b = SnapshotFixture.report(domain: "beta.example", availability: .available)
156
157 let result = DiffService.compare(domainA: a, domainB: b)
158
159 XCTAssertEqual(result.domainA, "alpha.example")
160 XCTAssertEqual(result.domainB, "beta.example")
161 XCTAssertTrue(result.changeCount > 0)
162 }
163}
DomainDigTests/DomainDataPortabilityServiceTests.swift added +124
@@ -0,0 +1,124 @@
1import XCTest
2@testable import DomainDig
3
4/// Characterization tests for the portability layer's deterministic core: the
5/// CSV round-trip and the merge/dedup semantics that `load*/save*` apply. Storage
6/// is exercised through an ephemeral `UserDefaults` suite so nothing touches the
7/// real app domain.
8final class DomainDataPortabilityServiceTests: XCTestCase {
9 private let suiteName = "DomainDigTests.portability"
10 private var defaults: UserDefaults!
11 private let base = Date(timeIntervalSince1970: 1_700_000_000)
12
13 override func setUp() {
14 super.setUp()
15 defaults = UserDefaults(suiteName: suiteName)
16 defaults.removePersistentDomain(forName: suiteName)
17 }
18
19 override func tearDown() {
20 defaults.removePersistentDomain(forName: suiteName)
21 defaults = nil
22 super.tearDown()
23 }
24
25 // MARK: - CSV round-trip
26
27 func testTrackedDomainCSVRoundTripPreservesFields() throws {
28 let original = TrackedDomain(
29 domain: "csv.example",
30 createdAt: base,
31 updatedAt: base.addingTimeInterval(3_600),
32 note: "keep an eye on this",
33 isPinned: true,
34 monitoringEnabled: false,
35 lastKnownAvailability: .registered,
36 certificateWarningLevel: .warning,
37 certificateDaysRemaining: 12
38 )
39
40 let csv = DataPortabilityCSV.trackedDomains([original])
41 let parsed = try DataPortabilityCSV.parseTrackedDomains(from: csv)
42
43 XCTAssertEqual(parsed.count, 1)
44 let restored = try XCTUnwrap(parsed.first)
45 XCTAssertEqual(restored.domain, "csv.example")
46 XCTAssertEqual(restored.note, "keep an eye on this")
47 XCTAssertTrue(restored.isPinned)
48 XCTAssertFalse(restored.monitoringEnabled)
49 XCTAssertEqual(restored.lastKnownAvailability, .registered)
50 XCTAssertEqual(restored.certificateWarningLevel, .warning)
51 XCTAssertEqual(restored.certificateDaysRemaining, 12)
52 }
53
54 func testTrackedDomainCSVParseSkipsRowsWithoutDomain() throws {
55 let csv = """
56 domain,isPinned,monitoringEnabled
57 ,true,true
58 valid.example,false,true
59 """
60
61 let parsed = try DataPortabilityCSV.parseTrackedDomains(from: csv)
62
63 XCTAssertEqual(parsed.map(\.domain), ["valid.example"])
64 }
65
66 // MARK: - Merge / dedup
67
68 func testSaveLoadDeduplicatesSameDomainCaseInsensitively() {
69 let older = TrackedDomain(
70 domain: "example.com",
71 createdAt: base.addingTimeInterval(-100),
72 updatedAt: base.addingTimeInterval(-50),
73 isPinned: false,
74 monitoringEnabled: false
75 )
76 let newer = TrackedDomain(
77 domain: "EXAMPLE.com",
78 createdAt: base.addingTimeInterval(-80),
79 updatedAt: base.addingTimeInterval(-10),
80 isPinned: true,
81 monitoringEnabled: false
82 )
83
84 DomainDataPortabilityService.saveTrackedDomains([older, newer], defaults: defaults)
85 let loaded = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
86
87 XCTAssertEqual(loaded.count, 1, "same domain differing only in case must collapse to one entry")
88 let merged = loaded[0]
89 XCTAssertEqual(merged.domain, "example.com")
90 XCTAssertTrue(merged.isPinned, "pinned state is OR-merged")
91 XCTAssertFalse(merged.monitoringEnabled)
92 XCTAssertEqual(merged.createdAt, base.addingTimeInterval(-100), "createdAt takes the earliest")
93 XCTAssertEqual(merged.updatedAt, base.addingTimeInterval(-10), "updatedAt takes the latest")
94 }
95
96 func testSaveLoadKeepsDistinctDomainsSortedByRecency() {
97 let domains = [
98 TrackedDomain(domain: "old.example", updatedAt: base.addingTimeInterval(-300)),
99 TrackedDomain(domain: "new.example", updatedAt: base.addingTimeInterval(-10)),
100 TrackedDomain(domain: "mid.example", updatedAt: base.addingTimeInterval(-100))
101 ]
102
103 DomainDataPortabilityService.saveTrackedDomains(domains, defaults: defaults)
104 let loaded = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
105
106 XCTAssertEqual(loaded.map(\.domain), ["new.example", "mid.example", "old.example"])
107 }
108
109 func testLoadTrackedDomainsEmptyWhenUnset() {
110 XCTAssertTrue(DomainDataPortabilityService.loadTrackedDomains(defaults: defaults).isEmpty)
111 }
112
113 // MARK: - Recent searches
114
115 func testRecentSearchesRoundTripAndTruncateToTwenty() {
116 let values = (0..<30).map { "domain\($0).example" }
117
118 DomainDataPortabilityService.saveRecentSearches(values, defaults: defaults)
119 let loaded = DomainDataPortabilityService.loadRecentSearches(defaults: defaults)
120
121 XCTAssertEqual(loaded.count, 20, "recent searches are capped at 20")
122 XCTAssertEqual(loaded, Array(values.prefix(20)), "order is preserved")
123 }
124}
DomainDigTests/DomainReportBuilderTests.swift added +130
@@ -0,0 +1,130 @@
1import XCTest
2@testable import DomainDig
3
4/// Characterization tests for the snapshot → report projection. These lock the
5/// field-mapping and derivation rules the export/diff contracts depend on.
6final class DomainReportBuilderTests: XCTestCase {
7 private let builder = DomainReportBuilder()
8
9 func testCoreIdentityFieldsPassThrough() {
10 let report = builder.build(
11 from: SnapshotFixture.snapshot(
12 domain: "mapped.example",
13 resolverURLString: "https://r.example/dns-query"
14 ),
15 deriveChangeSummary: false
16 )
17
18 XCTAssertEqual(report.domain, "mapped.example")
19 XCTAssertEqual(report.timestamp, SnapshotFixture.referenceDate)
20 XCTAssertEqual(report.resolverURLString, "https://r.example/dns-query")
21 XCTAssertFalse(report.metadata.schemaVersion.isEmpty)
22 }
23
24 func testAvailabilityDefaultsToUnknownWhenAbsent() {
25 let unknown = builder.build(from: SnapshotFixture.snapshot(availability: nil), deriveChangeSummary: false)
26 XCTAssertEqual(unknown.availability, .unknown)
27
28 let registered = builder.build(from: SnapshotFixture.snapshot(availability: .registered), deriveChangeSummary: false)
29 XCTAssertEqual(registered.availability, .registered)
30 }
31
32 func testPrimaryIPComesFromFirstARecord() {
33 let report = builder.build(
34 from: SnapshotFixture.snapshot(
35 dnsSections: [
36 SnapshotFixture.dnsSection(type: .A, values: ["198.51.100.7", "198.51.100.8"]),
37 SnapshotFixture.dnsSection(type: .AAAA, values: ["2001:db8::1"])
38 ]
39 ),
40 deriveChangeSummary: false
41 )
42
43 XCTAssertEqual(report.dns.primaryIP, "198.51.100.7")
44 XCTAssertEqual(report.network.primaryIP, "198.51.100.7")
45 }
46
47 func testPrimaryIPNilWithoutARecord() {
48 let report = builder.build(
49 from: SnapshotFixture.snapshot(
50 dnsSections: [SnapshotFixture.dnsSection(type: .MX, values: ["mail.example.com"])]
51 ),
52 deriveChangeSummary: false
53 )
54 XCTAssertNil(report.dns.primaryIP)
55 }
56
57 func testDNSSECDerivedFromSections() {
58 let signed = builder.build(
59 from: SnapshotFixture.snapshot(
60 dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"], dnssecSigned: true)]
61 ),
62 deriveChangeSummary: false
63 )
64 XCTAssertEqual(signed.dns.dnssecSigned, true)
65
66 let unknown = builder.build(
67 from: SnapshotFixture.snapshot(
68 dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"])]
69 ),
70 deriveChangeSummary: false
71 )
72 XCTAssertNil(unknown.dns.dnssecSigned)
73 }
74
75 func testTLSStatusReflectsCertificatePresence() {
76 let valid = builder.build(
77 from: SnapshotFixture.snapshot(sslInfo: SnapshotFixture.certificate()),
78 deriveChangeSummary: false
79 )
80 XCTAssertEqual(valid.web.tlsStatus, "valid")
81
82 let missing = builder.build(from: SnapshotFixture.snapshot(sslInfo: nil), deriveChangeSummary: false)
83 XCTAssertEqual(missing.web.tlsStatus, "unavailable")
84 }
85
86 func testWebHeaderCountAndFinalURL() {
87 let report = builder.build(
88 from: SnapshotFixture.snapshot(
89 httpHeaders: [
90 HTTPHeader(name: "Content-Type", value: "text/html"),
91 HTTPHeader(name: "Strict-Transport-Security", value: "max-age=63072000")
92 ],
93 httpStatusCode: 200
94 ),
95 deriveChangeSummary: false
96 )
97
98 XCTAssertEqual(report.web.headerCount, 2)
99 XCTAssertEqual(report.web.statusCode, 200)
100 XCTAssertNil(report.web.finalURL, "no redirect chain means no final URL")
101 }
102
103 func testPartialSnapshotAndValidationIssuesPropagate() {
104 let report = builder.build(
105 from: SnapshotFixture.snapshot(
106 isPartialSnapshot: true,
107 validationIssues: ["missing DNS", "stale WHOIS"]
108 ),
109 deriveChangeSummary: false
110 )
111
112 XCTAssertTrue(report.isPartialSnapshot)
113 XCTAssertEqual(report.validationIssues, ["missing DNS", "stale WHOIS"])
114 XCTAssertTrue(report.metadata.isPartialSnapshot)
115 XCTAssertEqual(report.metadata.validationIssues, ["missing DNS", "stale WHOIS"])
116 }
117
118 func testRecordSectionsArePreserved() {
119 let sections = [
120 SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"]),
121 SnapshotFixture.dnsSection(type: .MX, values: ["mail.example.com"])
122 ]
123 let report = builder.build(
124 from: SnapshotFixture.snapshot(dnsSections: sections),
125 deriveChangeSummary: false
126 )
127
128 XCTAssertEqual(Set(report.dns.recordSections.map(\.recordType)), [.A, .MX])
129 }
130}
DomainDigTests/DomainReportExporterTests.swift added +107
@@ -0,0 +1,107 @@
1import XCTest
2@testable import DomainDig
3
4/// Characterization tests for the export surface. These pin the format dispatch,
5/// the JSON round-trip (the canonical machine contract), and the structural
6/// invariants of the human-readable formats.
7final class DomainReportExporterTests: XCTestCase {
8
9 private let decoder: JSONDecoder = {
10 let decoder = JSONDecoder()
11 decoder.dateDecodingStrategy = .iso8601
12 return decoder
13 }()
14
15 func testEveryFormatProducesNonEmptyData() throws {
16 let report = SnapshotFixture.report(availability: .registered)
17 for format in DomainExportFormat.allCases {
18 let data = try DomainReportExporter.data(for: report, format: format)
19 XCTAssertFalse(data.isEmpty, "\(format.rawValue) export was empty")
20 }
21 }
22
23 func testJSONExportRoundTripsToReport() throws {
24 let report = SnapshotFixture.report(domain: "roundtrip.example", availability: .registered)
25
26 let data = try DomainReportExporter.data(for: report, format: .json)
27 let decoded = try decoder.decode(DomainReport.self, from: data)
28
29 XCTAssertEqual(decoded.domain, "roundtrip.example")
30 XCTAssertEqual(decoded.availability, .registered)
31 XCTAssertEqual(decoded.timestamp, report.timestamp)
32 }
33
34 func testBatchJSONExportRoundTripsToReportArray() throws {
35 let reports = [
36 SnapshotFixture.report(domain: "one.example"),
37 SnapshotFixture.report(domain: "two.example")
38 ]
39
40 let data = try DomainReportExporter.data(for: reports, format: .json, title: "Batch")
41 let decoded = try decoder.decode([DomainReport].self, from: data)
42
43 XCTAssertEqual(decoded.map(\.domain), ["one.example", "two.example"])
44 }
45
46 func testCSVHasHeaderAndOneRowPerReport() {
47 let reports = [
48 SnapshotFixture.report(domain: "alpha.example"),
49 SnapshotFixture.report(domain: "beta.example")
50 ]
51
52 let csv = DomainReportExporter.csv(for: reports)
53 let lines = csv.split(separator: "\n", omittingEmptySubsequences: false)
54
55 XCTAssertEqual(lines.count, 3, "one header line plus one row per report")
56 XCTAssertTrue(lines[0].contains("\"domain\""))
57 XCTAssertTrue(csv.contains("\"alpha.example\""))
58 XCTAssertTrue(csv.contains("\"beta.example\""))
59 }
60
61 func testMarkdownIsHeadedAndNamesTheDomain() {
62 let markdown = DomainReportExporter.markdown(for: SnapshotFixture.report(domain: "md.example"))
63
64 XCTAssertTrue(markdown.hasPrefix("# "), "markdown export should open with an H1")
65 XCTAssertTrue(markdown.contains("md.example"))
66 }
67
68 func testTextExportNamesTheDomain() {
69 let text = DomainReportExporter.text(for: SnapshotFixture.report(domain: "txt.example"))
70 XCTAssertTrue(text.contains("txt.example"))
71 }
72
73 func testBatchExportsCarryTitleAndEveryDomain() {
74 let reports = [
75 SnapshotFixture.report(domain: "first.example"),
76 SnapshotFixture.report(domain: "second.example")
77 ]
78
79 let markdown = DomainReportExporter.batchMarkdown(for: reports, title: "Portfolio Sweep")
80 XCTAssertTrue(markdown.contains("Portfolio Sweep"))
81 XCTAssertTrue(markdown.contains("first.example"))
82 XCTAssertTrue(markdown.contains("second.example"))
83
84 let text = DomainReportExporter.batchText(for: reports, title: "Portfolio Sweep")
85 XCTAssertTrue(text.contains("Portfolio Sweep"))
86 XCTAssertTrue(text.contains("first.example"))
87 XCTAssertTrue(text.contains("second.example"))
88 }
89
90 func testPDFExportHasPDFSignature() throws {
91 let data = try DomainReportExporter.data(for: SnapshotFixture.report(), format: .pdf)
92 XCTAssertTrue(data.starts(with: Array("%PDF".utf8)), "PDF export should begin with the %PDF signature")
93 }
94
95 func testTimelineTextNamesTheDomain() {
96 let reports = [
97 SnapshotFixture.report(domain: "timeline.example", timestamp: SnapshotFixture.referenceDate),
98 SnapshotFixture.report(
99 domain: "timeline.example",
100 timestamp: SnapshotFixture.referenceDate.addingTimeInterval(86_400)
101 )
102 ]
103
104 let text = DomainReportExporter.timelineText(for: reports, domain: "timeline.example", includeDiffSummary: false)
105 XCTAssertTrue(text.contains("timeline.example"))
106 }
107}
DomainDigTests/Fixtures/SnapshotFixture.swift added +191
@@ -0,0 +1,191 @@
1import Foundation
2@testable import DomainDig
3
4/// Deterministic builders for the deep `LookupSnapshot` / `DomainReport` models
5/// so the core unit tests can construct inputs without wiring up every one of the
6/// ~75 snapshot fields at each call site.
7///
8/// `snapshot(...)` exposes only the fields the tests actually vary; everything
9/// else defaults to an empty/absent value. `report(...)` runs a snapshot through
10/// the real `DomainReportBuilder`, which is both the natural constructor for the
11/// otherwise-unconstructable `DomainReport` and, for the builder tests, the unit
12/// under test.
13enum SnapshotFixture {
14 /// Fixed instant so timestamp-derived output (diff ranges, export headers) is
15 /// stable across runs.
16 static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000)
17
18 static let defaultResolverURL = "https://resolver.example/dns-query"
19
20 static func snapshot(
21 domain: String = "example.com",
22 timestamp: Date = referenceDate,
23 resolverURLString: String = defaultResolverURL,
24 resultSource: LookupResultSource = .live,
25 availability: DomainAvailabilityStatus? = nil,
26 ownership: DomainOwnership? = nil,
27 dnsSections: [DNSSection] = [],
28 ptrRecord: String? = nil,
29 sslInfo: SSLCertificateInfo? = nil,
30 httpHeaders: [HTTPHeader] = [],
31 httpStatusCode: Int? = nil,
32 httpSecurityGrade: String? = nil,
33 subdomains: [DiscoveredSubdomain] = [],
34 extendedSubdomains: [DiscoveredSubdomain] = [],
35 isPartialSnapshot: Bool = false,
36 validationIssues: [String] = [],
37 changeSummary: DomainChangeSummary? = nil
38 ) -> LookupSnapshot {
39 LookupSnapshot(
40 historyEntryID: nil,
41 domain: domain,
42 timestamp: timestamp,
43 trackedDomainID: nil,
44 note: nil,
45 appVersion: "test",
46 resolverDisplayName: "Test Resolver",
47 resolverURLString: resolverURLString,
48 dataSources: [],
49 provenanceBySection: [:],
50 availabilityConfidence: nil,
51 ownershipConfidence: nil,
52 subdomainConfidence: nil,
53 emailSecurityConfidence: nil,
54 geolocationConfidence: nil,
55 errorDetails: [:],
56 isPartialSnapshot: isPartialSnapshot,
57 validationIssues: validationIssues,
58 totalLookupDurationMs: nil,
59 snapshotIndex: nil,
60 previousSnapshotID: nil,
61 changeCount: 0,
62 severitySummary: nil,
63 dnsSections: dnsSections,
64 dnsError: nil,
65 availabilityResult: availability.map { DomainAvailabilityResult(domain: domain, status: $0) },
66 suggestions: [],
67 sslInfo: sslInfo,
68 sslError: nil,
69 hstsPreloaded: nil,
70 httpHeaders: httpHeaders,
71 httpSecurityGrade: httpSecurityGrade,
72 httpStatusCode: httpStatusCode,
73 httpResponseTimeMs: nil,
74 httpProtocol: nil,
75 http3Advertised: false,
76 httpHeadersError: nil,
77 reachabilityResults: [],
78 reachabilityError: nil,
79 ipGeolocation: nil,
80 ipGeolocationError: nil,
81 emailSecurity: nil,
82 emailSecurityError: nil,
83 ownership: ownership,
84 ownershipError: nil,
85 ownershipHistory: [],
86 ownershipHistoryError: nil,
87 inferredProvider: nil,
88 priorProviders: [],
89 domainClassification: nil,
90 ownershipTransitions: [],
91 hostingTransitions: [],
92 subdomainHistory: [],
93 riskSignals: [],
94 intelligenceTimeline: [],
95 ptrRecord: ptrRecord,
96 ptrError: nil,
97 redirectChain: [],
98 redirectChainError: nil,
99 subdomains: subdomains,
100 subdomainsError: nil,
101 extendedSubdomains: extendedSubdomains,
102 extendedSubdomainsError: nil,
103 dnsHistory: [],
104 dnsHistoryError: nil,
105 domainPricing: nil,
106 domainPricingError: nil,
107 reputation: nil,
108 reputationError: nil,
109 portScanResults: [],
110 portScanError: nil,
111 changeSummary: changeSummary,
112 resultSource: resultSource,
113 cachedSections: [],
114 statusMessage: nil
115 )
116 }
117
118 static func report(
119 domain: String = "example.com",
120 timestamp: Date = referenceDate,
121 resolverURLString: String = defaultResolverURL,
122 resultSource: LookupResultSource = .live,
123 availability: DomainAvailabilityStatus? = nil,
124 ownership: DomainOwnership? = nil,
125 dnsSections: [DNSSection] = [],
126 ptrRecord: String? = nil,
127 sslInfo: SSLCertificateInfo? = nil,
128 httpHeaders: [HTTPHeader] = [],
129 httpStatusCode: Int? = nil,
130 httpSecurityGrade: String? = nil,
131 subdomains: [DiscoveredSubdomain] = [],
132 extendedSubdomains: [DiscoveredSubdomain] = [],
133 isPartialSnapshot: Bool = false,
134 validationIssues: [String] = []
135 ) -> DomainReport {
136 DomainReportBuilder().build(
137 from: snapshot(
138 domain: domain,
139 timestamp: timestamp,
140 resolverURLString: resolverURLString,
141 resultSource: resultSource,
142 availability: availability,
143 ownership: ownership,
144 dnsSections: dnsSections,
145 ptrRecord: ptrRecord,
146 sslInfo: sslInfo,
147 httpHeaders: httpHeaders,
148 httpStatusCode: httpStatusCode,
149 httpSecurityGrade: httpSecurityGrade,
150 subdomains: subdomains,
151 extendedSubdomains: extendedSubdomains,
152 isPartialSnapshot: isPartialSnapshot,
153 validationIssues: validationIssues
154 ),
155 deriveChangeSummary: false
156 )
157 }
158
159 // MARK: - Nested model conveniences
160
161 static func dnsSection(
162 type: DNSRecordType,
163 values: [String],
164 ttl: Int = 300,
165 dnssecSigned: Bool? = nil,
166 wildcards: [String] = []
167 ) -> DNSSection {
168 DNSSection(
169 recordType: type,
170 records: values.map { DNSRecord(value: $0, ttl: ttl) },
171 wildcardRecords: wildcards.map { DNSRecord(value: $0, ttl: ttl) },
172 dnssecSigned: dnssecSigned
173 )
174 }
175
176 static func certificate(
177 commonName: String = "example.com",
178 issuer: String = "Test CA",
179 daysUntilExpiry: Int = 90
180 ) -> SSLCertificateInfo {
181 SSLCertificateInfo(
182 commonName: commonName,
183 subjectAltNames: [commonName],
184 issuer: issuer,
185 validFrom: referenceDate,
186 validUntil: referenceDate.addingTimeInterval(Double(daysUntilExpiry) * 86_400),
187 daysUntilExpiry: daysUntilExpiry,
188 chainDepth: 1
189 )
190 }
191}