diff options
Diffstat (limited to 'DomainDigTests')
| -rw-r--r-- | DomainDigTests/DiffServiceTests.swift | 163 | ||||
| -rw-r--r-- | DomainDigTests/DomainDataPortabilityServiceTests.swift | 124 | ||||
| -rw-r--r-- | DomainDigTests/DomainReportBuilderTests.swift | 130 | ||||
| -rw-r--r-- | DomainDigTests/DomainReportExporterTests.swift | 107 | ||||
| -rw-r--r-- | DomainDigTests/Fixtures/SnapshotFixture.swift | 191 |
5 files changed, 715 insertions, 0 deletions
diff --git a/DomainDigTests/DiffServiceTests.swift b/DomainDigTests/DiffServiceTests.swift new file mode 100644 index 0000000..a3a367c --- /dev/null +++ b/DomainDigTests/DiffServiceTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import DomainDig + +/// Characterization tests for the field-level diff engine. `DiffService` is a +/// pure function over two `DomainReport`s, so every case here pins observable +/// behavior (change classification, normalization, summary phrasing) against a +/// deterministic fixture. +final class DiffServiceTests: XCTestCase { + + func testIdenticalReportsProduceNoChanges() { + let report = SnapshotFixture.report( + availability: .registered, + ownership: DomainOwnership(registrar: "Example Registrar") + ) + + let diff = DiffService.compare(from: report, to: report) + + XCTAssertEqual(diff.changeCount, 0) + XCTAssertTrue(diff.changedSectionTitles.isEmpty) + XCTAssertFalse(diff.sections.contains { $0.hasChanges }) + } + + func testAvailabilityTransitionIsReportedAsChanged() { + let old = SnapshotFixture.report(availability: .available) + let new = SnapshotFixture.report(availability: .registered) + + let diff = DiffService.compare(from: old, to: new) + + let availability = try? XCTUnwrap(diff.sections.first { $0.id == "availability" }) + let item = availability?.items.first { $0.id == "availability" } + XCTAssertEqual(item?.changeType, .changed) + XCTAssertEqual(item?.oldValue, "Available") + XCTAssertEqual(item?.newValue, "Registered") + XCTAssertTrue(diff.changedSectionTitles.contains("Domain / Availability")) + } + + func testPrimaryIPChangeSurfacesInAvailabilitySection() { + let old = SnapshotFixture.report( + availability: .registered, + dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.10"])] + ) + let new = SnapshotFixture.report( + availability: .registered, + dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.20"])] + ) + + let diff = DiffService.compare(from: old, to: new) + let item = diff.sections + .first { $0.id == "availability" }? + .items.first { $0.id == "primary-ip" } + + XCTAssertEqual(item?.changeType, .changed) + XCTAssertEqual(item?.oldValue, "203.0.113.10") + XCTAssertEqual(item?.newValue, "203.0.113.20") + } + + func testCaseAndWhitespaceDifferencesAreNotChanges() { + let old = SnapshotFixture.report(ownership: DomainOwnership(registrar: "GoDaddy")) + let new = SnapshotFixture.report(ownership: DomainOwnership(registrar: " godaddy ")) + + let diff = DiffService.compare(from: old, to: new) + let item = diff.sections + .first { $0.id == "ownership" }? + .items.first { $0.id == "registrar" } + + XCTAssertEqual(item?.changeType, .unchanged, "case- and whitespace-only differences must not register as changes") + } + + func testAddedAndRemovedOwnershipFields() { + let absent = SnapshotFixture.report(ownership: nil) + let present = SnapshotFixture.report(ownership: DomainOwnership(registrar: "Example Registrar")) + + let added = DiffService.compare(from: absent, to: present) + .sections.first { $0.id == "ownership" }? + .items.first { $0.id == "registrar" } + XCTAssertEqual(added?.changeType, .added) + XCTAssertNil(added?.oldValue) + XCTAssertEqual(added?.newValue, "Example Registrar") + + let removed = DiffService.compare(from: present, to: absent) + .sections.first { $0.id == "ownership" }? + .items.first { $0.id == "registrar" } + XCTAssertEqual(removed?.changeType, .removed) + XCTAssertEqual(removed?.oldValue, "Example Registrar") + XCTAssertNil(removed?.newValue) + } + + func testDNSRecordValueChangeIsDetected() { + let old = SnapshotFixture.report( + dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns2.example.com"])] + ) + let new = SnapshotFixture.report( + dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns3.example.com"])] + ) + + let dns = DiffService.compare(from: old, to: new).sections.first { $0.id == "dns" } + let records = dns?.items.first { $0.id == "dns-ns-records" } + XCTAssertEqual(records?.changeType, .changed) + } + + func testDNSRecordReorderIsNotAChange() { + // Values are normalized (sorted, lowercased) before comparison, so a pure + // reorder must diff as unchanged. + let old = SnapshotFixture.report( + dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["ns1.example.com", "ns2.example.com"])] + ) + let new = SnapshotFixture.report( + dnsSections: [SnapshotFixture.dnsSection(type: .NS, values: ["NS2.example.com", "NS1.example.com"])] + ) + + let records = DiffService.compare(from: old, to: new) + .sections.first { $0.id == "dns" }? + .items.first { $0.id == "dns-ns-records" } + XCTAssertEqual(records?.changeType, .unchanged) + } + + func testSummaryMessagePhrasing() { + XCTAssertEqual(DiffService.summaryMessage(from: [], changeCount: 0), "No meaningful changes") + XCTAssertEqual(DiffService.summaryMessage(from: ["DNS"], changeCount: 1), "DNS changed") + XCTAssertEqual( + DiffService.summaryMessage(from: ["DNS", "Ownership"], changeCount: 3), + "DNS and ownership changed (3 items)" + ) + } + + func testContextNoteFlagsDifferentResolvers() { + let old = SnapshotFixture.report(resolverURLString: "https://one.example/dns-query") + let new = SnapshotFixture.report(resolverURLString: "https://two.example/dns-query") + + let note = DiffService.comparisonContextNote(from: old, to: new) + XCTAssertEqual(note, "Compared snapshots used different DNS resolvers.") + } + + func testContextNoteNilWhenResolversMatch() { + let report = SnapshotFixture.report() + XCTAssertNil(DiffService.comparisonContextNote(from: report, to: report)) + } + + func testCertificateWarningLevelThresholds() { + func level(daysUntilExpiry days: Int?) -> CertificateWarningLevel { + let ssl = days.map { SnapshotFixture.certificate(daysUntilExpiry: $0) } + return DiffService.certificateWarningLevel(for: SnapshotFixture.snapshot(sslInfo: ssl)) + } + + XCTAssertEqual(level(daysUntilExpiry: nil), .none) + XCTAssertEqual(level(daysUntilExpiry: 45), .none) + XCTAssertEqual(level(daysUntilExpiry: 29), .warning) + XCTAssertEqual(level(daysUntilExpiry: 14), .warning) + XCTAssertEqual(level(daysUntilExpiry: 13), .critical) + XCTAssertEqual(level(daysUntilExpiry: 0), .critical) + } + + func testCrossDomainComparisonPairsBothDomains() { + let a = SnapshotFixture.report(domain: "alpha.example", availability: .registered) + let b = SnapshotFixture.report(domain: "beta.example", availability: .available) + + let result = DiffService.compare(domainA: a, domainB: b) + + XCTAssertEqual(result.domainA, "alpha.example") + XCTAssertEqual(result.domainB, "beta.example") + XCTAssertTrue(result.changeCount > 0) + } +} diff --git a/DomainDigTests/DomainDataPortabilityServiceTests.swift b/DomainDigTests/DomainDataPortabilityServiceTests.swift new file mode 100644 index 0000000..ca2c72c --- /dev/null +++ b/DomainDigTests/DomainDataPortabilityServiceTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import DomainDig + +/// Characterization tests for the portability layer's deterministic core: the +/// CSV round-trip and the merge/dedup semantics that `load*/save*` apply. Storage +/// is exercised through an ephemeral `UserDefaults` suite so nothing touches the +/// real app domain. +final class DomainDataPortabilityServiceTests: XCTestCase { + private let suiteName = "DomainDigTests.portability" + private var defaults: UserDefaults! + private let base = Date(timeIntervalSince1970: 1_700_000_000) + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + super.tearDown() + } + + // MARK: - CSV round-trip + + func testTrackedDomainCSVRoundTripPreservesFields() throws { + let original = TrackedDomain( + domain: "csv.example", + createdAt: base, + updatedAt: base.addingTimeInterval(3_600), + note: "keep an eye on this", + isPinned: true, + monitoringEnabled: false, + lastKnownAvailability: .registered, + certificateWarningLevel: .warning, + certificateDaysRemaining: 12 + ) + + let csv = DataPortabilityCSV.trackedDomains([original]) + let parsed = try DataPortabilityCSV.parseTrackedDomains(from: csv) + + XCTAssertEqual(parsed.count, 1) + let restored = try XCTUnwrap(parsed.first) + XCTAssertEqual(restored.domain, "csv.example") + XCTAssertEqual(restored.note, "keep an eye on this") + XCTAssertTrue(restored.isPinned) + XCTAssertFalse(restored.monitoringEnabled) + XCTAssertEqual(restored.lastKnownAvailability, .registered) + XCTAssertEqual(restored.certificateWarningLevel, .warning) + XCTAssertEqual(restored.certificateDaysRemaining, 12) + } + + func testTrackedDomainCSVParseSkipsRowsWithoutDomain() throws { + let csv = """ + domain,isPinned,monitoringEnabled + ,true,true + valid.example,false,true + """ + + let parsed = try DataPortabilityCSV.parseTrackedDomains(from: csv) + + XCTAssertEqual(parsed.map(\.domain), ["valid.example"]) + } + + // MARK: - Merge / dedup + + func testSaveLoadDeduplicatesSameDomainCaseInsensitively() { + let older = TrackedDomain( + domain: "example.com", + createdAt: base.addingTimeInterval(-100), + updatedAt: base.addingTimeInterval(-50), + isPinned: false, + monitoringEnabled: false + ) + let newer = TrackedDomain( + domain: "EXAMPLE.com", + createdAt: base.addingTimeInterval(-80), + updatedAt: base.addingTimeInterval(-10), + isPinned: true, + monitoringEnabled: false + ) + + DomainDataPortabilityService.saveTrackedDomains([older, newer], defaults: defaults) + let loaded = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults) + + XCTAssertEqual(loaded.count, 1, "same domain differing only in case must collapse to one entry") + let merged = loaded[0] + XCTAssertEqual(merged.domain, "example.com") + XCTAssertTrue(merged.isPinned, "pinned state is OR-merged") + XCTAssertFalse(merged.monitoringEnabled) + XCTAssertEqual(merged.createdAt, base.addingTimeInterval(-100), "createdAt takes the earliest") + XCTAssertEqual(merged.updatedAt, base.addingTimeInterval(-10), "updatedAt takes the latest") + } + + func testSaveLoadKeepsDistinctDomainsSortedByRecency() { + let domains = [ + TrackedDomain(domain: "old.example", updatedAt: base.addingTimeInterval(-300)), + TrackedDomain(domain: "new.example", updatedAt: base.addingTimeInterval(-10)), + TrackedDomain(domain: "mid.example", updatedAt: base.addingTimeInterval(-100)) + ] + + DomainDataPortabilityService.saveTrackedDomains(domains, defaults: defaults) + let loaded = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults) + + XCTAssertEqual(loaded.map(\.domain), ["new.example", "mid.example", "old.example"]) + } + + func testLoadTrackedDomainsEmptyWhenUnset() { + XCTAssertTrue(DomainDataPortabilityService.loadTrackedDomains(defaults: defaults).isEmpty) + } + + // MARK: - Recent searches + + func testRecentSearchesRoundTripAndTruncateToTwenty() { + let values = (0..<30).map { "domain\($0).example" } + + DomainDataPortabilityService.saveRecentSearches(values, defaults: defaults) + let loaded = DomainDataPortabilityService.loadRecentSearches(defaults: defaults) + + XCTAssertEqual(loaded.count, 20, "recent searches are capped at 20") + XCTAssertEqual(loaded, Array(values.prefix(20)), "order is preserved") + } +} diff --git a/DomainDigTests/DomainReportBuilderTests.swift b/DomainDigTests/DomainReportBuilderTests.swift new file mode 100644 index 0000000..7c8db15 --- /dev/null +++ b/DomainDigTests/DomainReportBuilderTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import DomainDig + +/// Characterization tests for the snapshot → report projection. These lock the +/// field-mapping and derivation rules the export/diff contracts depend on. +final class DomainReportBuilderTests: XCTestCase { + private let builder = DomainReportBuilder() + + func testCoreIdentityFieldsPassThrough() { + let report = builder.build( + from: SnapshotFixture.snapshot( + domain: "mapped.example", + resolverURLString: "https://r.example/dns-query" + ), + deriveChangeSummary: false + ) + + XCTAssertEqual(report.domain, "mapped.example") + XCTAssertEqual(report.timestamp, SnapshotFixture.referenceDate) + XCTAssertEqual(report.resolverURLString, "https://r.example/dns-query") + XCTAssertFalse(report.metadata.schemaVersion.isEmpty) + } + + func testAvailabilityDefaultsToUnknownWhenAbsent() { + let unknown = builder.build(from: SnapshotFixture.snapshot(availability: nil), deriveChangeSummary: false) + XCTAssertEqual(unknown.availability, .unknown) + + let registered = builder.build(from: SnapshotFixture.snapshot(availability: .registered), deriveChangeSummary: false) + XCTAssertEqual(registered.availability, .registered) + } + + func testPrimaryIPComesFromFirstARecord() { + let report = builder.build( + from: SnapshotFixture.snapshot( + dnsSections: [ + SnapshotFixture.dnsSection(type: .A, values: ["198.51.100.7", "198.51.100.8"]), + SnapshotFixture.dnsSection(type: .AAAA, values: ["2001:db8::1"]) + ] + ), + deriveChangeSummary: false + ) + + XCTAssertEqual(report.dns.primaryIP, "198.51.100.7") + XCTAssertEqual(report.network.primaryIP, "198.51.100.7") + } + + func testPrimaryIPNilWithoutARecord() { + let report = builder.build( + from: SnapshotFixture.snapshot( + dnsSections: [SnapshotFixture.dnsSection(type: .MX, values: ["mail.example.com"])] + ), + deriveChangeSummary: false + ) + XCTAssertNil(report.dns.primaryIP) + } + + func testDNSSECDerivedFromSections() { + let signed = builder.build( + from: SnapshotFixture.snapshot( + dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"], dnssecSigned: true)] + ), + deriveChangeSummary: false + ) + XCTAssertEqual(signed.dns.dnssecSigned, true) + + let unknown = builder.build( + from: SnapshotFixture.snapshot( + dnsSections: [SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"])] + ), + deriveChangeSummary: false + ) + XCTAssertNil(unknown.dns.dnssecSigned) + } + + func testTLSStatusReflectsCertificatePresence() { + let valid = builder.build( + from: SnapshotFixture.snapshot(sslInfo: SnapshotFixture.certificate()), + deriveChangeSummary: false + ) + XCTAssertEqual(valid.web.tlsStatus, "valid") + + let missing = builder.build(from: SnapshotFixture.snapshot(sslInfo: nil), deriveChangeSummary: false) + XCTAssertEqual(missing.web.tlsStatus, "unavailable") + } + + func testWebHeaderCountAndFinalURL() { + let report = builder.build( + from: SnapshotFixture.snapshot( + httpHeaders: [ + HTTPHeader(name: "Content-Type", value: "text/html"), + HTTPHeader(name: "Strict-Transport-Security", value: "max-age=63072000") + ], + httpStatusCode: 200 + ), + deriveChangeSummary: false + ) + + XCTAssertEqual(report.web.headerCount, 2) + XCTAssertEqual(report.web.statusCode, 200) + XCTAssertNil(report.web.finalURL, "no redirect chain means no final URL") + } + + func testPartialSnapshotAndValidationIssuesPropagate() { + let report = builder.build( + from: SnapshotFixture.snapshot( + isPartialSnapshot: true, + validationIssues: ["missing DNS", "stale WHOIS"] + ), + deriveChangeSummary: false + ) + + XCTAssertTrue(report.isPartialSnapshot) + XCTAssertEqual(report.validationIssues, ["missing DNS", "stale WHOIS"]) + XCTAssertTrue(report.metadata.isPartialSnapshot) + XCTAssertEqual(report.metadata.validationIssues, ["missing DNS", "stale WHOIS"]) + } + + func testRecordSectionsArePreserved() { + let sections = [ + SnapshotFixture.dnsSection(type: .A, values: ["203.0.113.1"]), + SnapshotFixture.dnsSection(type: .MX, values: ["mail.example.com"]) + ] + let report = builder.build( + from: SnapshotFixture.snapshot(dnsSections: sections), + deriveChangeSummary: false + ) + + XCTAssertEqual(Set(report.dns.recordSections.map(\.recordType)), [.A, .MX]) + } +} diff --git a/DomainDigTests/DomainReportExporterTests.swift b/DomainDigTests/DomainReportExporterTests.swift new file mode 100644 index 0000000..ddecabf --- /dev/null +++ b/DomainDigTests/DomainReportExporterTests.swift @@ -0,0 +1,107 @@ +import XCTest +@testable import DomainDig + +/// Characterization tests for the export surface. These pin the format dispatch, +/// the JSON round-trip (the canonical machine contract), and the structural +/// invariants of the human-readable formats. +final class DomainReportExporterTests: XCTestCase { + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + func testEveryFormatProducesNonEmptyData() throws { + let report = SnapshotFixture.report(availability: .registered) + for format in DomainExportFormat.allCases { + let data = try DomainReportExporter.data(for: report, format: format) + XCTAssertFalse(data.isEmpty, "\(format.rawValue) export was empty") + } + } + + func testJSONExportRoundTripsToReport() throws { + let report = SnapshotFixture.report(domain: "roundtrip.example", availability: .registered) + + let data = try DomainReportExporter.data(for: report, format: .json) + let decoded = try decoder.decode(DomainReport.self, from: data) + + XCTAssertEqual(decoded.domain, "roundtrip.example") + XCTAssertEqual(decoded.availability, .registered) + XCTAssertEqual(decoded.timestamp, report.timestamp) + } + + func testBatchJSONExportRoundTripsToReportArray() throws { + let reports = [ + SnapshotFixture.report(domain: "one.example"), + SnapshotFixture.report(domain: "two.example") + ] + + let data = try DomainReportExporter.data(for: reports, format: .json, title: "Batch") + let decoded = try decoder.decode([DomainReport].self, from: data) + + XCTAssertEqual(decoded.map(\.domain), ["one.example", "two.example"]) + } + + func testCSVHasHeaderAndOneRowPerReport() { + let reports = [ + SnapshotFixture.report(domain: "alpha.example"), + SnapshotFixture.report(domain: "beta.example") + ] + + let csv = DomainReportExporter.csv(for: reports) + let lines = csv.split(separator: "\n", omittingEmptySubsequences: false) + + XCTAssertEqual(lines.count, 3, "one header line plus one row per report") + XCTAssertTrue(lines[0].contains("\"domain\"")) + XCTAssertTrue(csv.contains("\"alpha.example\"")) + XCTAssertTrue(csv.contains("\"beta.example\"")) + } + + func testMarkdownIsHeadedAndNamesTheDomain() { + let markdown = DomainReportExporter.markdown(for: SnapshotFixture.report(domain: "md.example")) + + XCTAssertTrue(markdown.hasPrefix("# "), "markdown export should open with an H1") + XCTAssertTrue(markdown.contains("md.example")) + } + + func testTextExportNamesTheDomain() { + let text = DomainReportExporter.text(for: SnapshotFixture.report(domain: "txt.example")) + XCTAssertTrue(text.contains("txt.example")) + } + + func testBatchExportsCarryTitleAndEveryDomain() { + let reports = [ + SnapshotFixture.report(domain: "first.example"), + SnapshotFixture.report(domain: "second.example") + ] + + let markdown = DomainReportExporter.batchMarkdown(for: reports, title: "Portfolio Sweep") + XCTAssertTrue(markdown.contains("Portfolio Sweep")) + XCTAssertTrue(markdown.contains("first.example")) + XCTAssertTrue(markdown.contains("second.example")) + + let text = DomainReportExporter.batchText(for: reports, title: "Portfolio Sweep") + XCTAssertTrue(text.contains("Portfolio Sweep")) + XCTAssertTrue(text.contains("first.example")) + XCTAssertTrue(text.contains("second.example")) + } + + func testPDFExportHasPDFSignature() throws { + let data = try DomainReportExporter.data(for: SnapshotFixture.report(), format: .pdf) + XCTAssertTrue(data.starts(with: Array("%PDF".utf8)), "PDF export should begin with the %PDF signature") + } + + func testTimelineTextNamesTheDomain() { + let reports = [ + SnapshotFixture.report(domain: "timeline.example", timestamp: SnapshotFixture.referenceDate), + SnapshotFixture.report( + domain: "timeline.example", + timestamp: SnapshotFixture.referenceDate.addingTimeInterval(86_400) + ) + ] + + let text = DomainReportExporter.timelineText(for: reports, domain: "timeline.example", includeDiffSummary: false) + XCTAssertTrue(text.contains("timeline.example")) + } +} diff --git a/DomainDigTests/Fixtures/SnapshotFixture.swift b/DomainDigTests/Fixtures/SnapshotFixture.swift new file mode 100644 index 0000000..4129a30 --- /dev/null +++ b/DomainDigTests/Fixtures/SnapshotFixture.swift @@ -0,0 +1,191 @@ +import Foundation +@testable import DomainDig + +/// Deterministic builders for the deep `LookupSnapshot` / `DomainReport` models +/// so the core unit tests can construct inputs without wiring up every one of the +/// ~75 snapshot fields at each call site. +/// +/// `snapshot(...)` exposes only the fields the tests actually vary; everything +/// else defaults to an empty/absent value. `report(...)` runs a snapshot through +/// the real `DomainReportBuilder`, which is both the natural constructor for the +/// otherwise-unconstructable `DomainReport` and, for the builder tests, the unit +/// under test. +enum SnapshotFixture { + /// Fixed instant so timestamp-derived output (diff ranges, export headers) is + /// stable across runs. + static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + + static let defaultResolverURL = "https://resolver.example/dns-query" + + static func snapshot( + domain: String = "example.com", + timestamp: Date = referenceDate, + resolverURLString: String = defaultResolverURL, + resultSource: LookupResultSource = .live, + availability: DomainAvailabilityStatus? = nil, + ownership: DomainOwnership? = nil, + dnsSections: [DNSSection] = [], + ptrRecord: String? = nil, + sslInfo: SSLCertificateInfo? = nil, + httpHeaders: [HTTPHeader] = [], + httpStatusCode: Int? = nil, + httpSecurityGrade: String? = nil, + subdomains: [DiscoveredSubdomain] = [], + extendedSubdomains: [DiscoveredSubdomain] = [], + isPartialSnapshot: Bool = false, + validationIssues: [String] = [], + changeSummary: DomainChangeSummary? = nil + ) -> LookupSnapshot { + LookupSnapshot( + historyEntryID: nil, + domain: domain, + timestamp: timestamp, + trackedDomainID: nil, + note: nil, + appVersion: "test", + resolverDisplayName: "Test Resolver", + resolverURLString: resolverURLString, + dataSources: [], + provenanceBySection: [:], + availabilityConfidence: nil, + ownershipConfidence: nil, + subdomainConfidence: nil, + emailSecurityConfidence: nil, + geolocationConfidence: nil, + errorDetails: [:], + isPartialSnapshot: isPartialSnapshot, + validationIssues: validationIssues, + totalLookupDurationMs: nil, + snapshotIndex: nil, + previousSnapshotID: nil, + changeCount: 0, + severitySummary: nil, + dnsSections: dnsSections, + dnsError: nil, + availabilityResult: availability.map { DomainAvailabilityResult(domain: domain, status: $0) }, + suggestions: [], + sslInfo: sslInfo, + sslError: nil, + hstsPreloaded: nil, + httpHeaders: httpHeaders, + httpSecurityGrade: httpSecurityGrade, + httpStatusCode: httpStatusCode, + httpResponseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + httpHeadersError: nil, + reachabilityResults: [], + reachabilityError: nil, + ipGeolocation: nil, + ipGeolocationError: nil, + emailSecurity: nil, + emailSecurityError: nil, + ownership: ownership, + ownershipError: nil, + ownershipHistory: [], + ownershipHistoryError: nil, + inferredProvider: nil, + priorProviders: [], + domainClassification: nil, + ownershipTransitions: [], + hostingTransitions: [], + subdomainHistory: [], + riskSignals: [], + intelligenceTimeline: [], + ptrRecord: ptrRecord, + ptrError: nil, + redirectChain: [], + redirectChainError: nil, + subdomains: subdomains, + subdomainsError: nil, + extendedSubdomains: extendedSubdomains, + extendedSubdomainsError: nil, + dnsHistory: [], + dnsHistoryError: nil, + domainPricing: nil, + domainPricingError: nil, + reputation: nil, + reputationError: nil, + portScanResults: [], + portScanError: nil, + changeSummary: changeSummary, + resultSource: resultSource, + cachedSections: [], + statusMessage: nil + ) + } + + static func report( + domain: String = "example.com", + timestamp: Date = referenceDate, + resolverURLString: String = defaultResolverURL, + resultSource: LookupResultSource = .live, + availability: DomainAvailabilityStatus? = nil, + ownership: DomainOwnership? = nil, + dnsSections: [DNSSection] = [], + ptrRecord: String? = nil, + sslInfo: SSLCertificateInfo? = nil, + httpHeaders: [HTTPHeader] = [], + httpStatusCode: Int? = nil, + httpSecurityGrade: String? = nil, + subdomains: [DiscoveredSubdomain] = [], + extendedSubdomains: [DiscoveredSubdomain] = [], + isPartialSnapshot: Bool = false, + validationIssues: [String] = [] + ) -> DomainReport { + DomainReportBuilder().build( + from: snapshot( + domain: domain, + timestamp: timestamp, + resolverURLString: resolverURLString, + resultSource: resultSource, + availability: availability, + ownership: ownership, + dnsSections: dnsSections, + ptrRecord: ptrRecord, + sslInfo: sslInfo, + httpHeaders: httpHeaders, + httpStatusCode: httpStatusCode, + httpSecurityGrade: httpSecurityGrade, + subdomains: subdomains, + extendedSubdomains: extendedSubdomains, + isPartialSnapshot: isPartialSnapshot, + validationIssues: validationIssues + ), + deriveChangeSummary: false + ) + } + + // MARK: - Nested model conveniences + + static func dnsSection( + type: DNSRecordType, + values: [String], + ttl: Int = 300, + dnssecSigned: Bool? = nil, + wildcards: [String] = [] + ) -> DNSSection { + DNSSection( + recordType: type, + records: values.map { DNSRecord(value: $0, ttl: ttl) }, + wildcardRecords: wildcards.map { DNSRecord(value: $0, ttl: ttl) }, + dnssecSigned: dnssecSigned + ) + } + + static func certificate( + commonName: String = "example.com", + issuer: String = "Test CA", + daysUntilExpiry: Int = 90 + ) -> SSLCertificateInfo { + SSLCertificateInfo( + commonName: commonName, + subjectAltNames: [commonName], + issuer: issuer, + validFrom: referenceDate, + validUntil: referenceDate.addingTimeInterval(Double(daysUntilExpiry) * 86_400), + daysUntilExpiry: daysUntilExpiry, + chainDepth: 1 + ) + } +} |
