From 135303414358ad4ab6fce38152823ae44c8e4bc4 Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 11 May 2026 10:27:51 -0400 Subject: [PATCH] MistDemo: tier-1 unit tests for pure config + command helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests that don't require live CloudKit access — covers the cheapest slice of the patch-coverage gap on PR #298 (codecov/patch: 13%). New test files (all @testable, no network): - Configuration/AuthTokenConfigTests.swift — memberwise init + parsed init via InMemoryProvider, including the missing/empty api.token error path - Configuration/UploadAssetConfigTests.swift — memberwise init for every field - Configuration/TestIntegrationConfigTests.swift — defaults + custom values - Configuration/TestPrivateConfigTests.swift — defaults + private-DB pinning - Commands/QueryCommand/QueryCommandTests+ParseFilter.swift — full coverage of parseFilter / inferFieldValue / shouldIncludeField / buildComparisonFilter (parameterized over every operator alias and error path) - Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift — prefix, three-part format, suffix range, distinctness across 200 calls - Commands/DemoErrorsRunnerOutputTests.swift — describe(_:) placeholder + echo Source changes are minimal seams that mirror the existing DeleteCommand.mapConflict pattern (internal-static for testability): - QueryCommand: filter parsing helpers extracted into QueryCommand+FilterParsing.swift; private → internal static - CreateCommand.generateRecordName: private → internal 901 MistDemo tests pass; lint + swift-format clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MistDemoKit/Commands/CreateCommand.swift | 2 +- .../Commands/QueryCommand+FilterParsing.swift | 153 ++++++++++++++ .../MistDemoKit/Commands/QueryCommand.swift | 122 +---------- ...reateCommandTests+GenerateRecordName.swift | 80 ++++++++ .../DemoErrorsRunnerOutputTests.swift | 70 +++++++ .../QueryCommandTests+ParseFilter.swift | 189 ++++++++++++++++++ .../Configuration/AuthTokenConfigTests.swift | 133 ++++++++++++ .../TestIntegrationConfigTests.swift | 83 ++++++++ .../TestPrivateConfigTests.swift | 89 +++++++++ .../UploadAssetConfigTests.swift | 102 ++++++++++ 10 files changed, 901 insertions(+), 122 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift index da074b34..87ff9012 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -96,7 +96,7 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting { } /// Generate a unique record name - private func generateRecordName() -> String { + internal func generateRecordName() -> String { let timestamp = Int(Date().timeIntervalSince1970) let minSuffix = MistDemoConstants.Limits.randomSuffixMin let maxSuffix = MistDemoConstants.Limits.randomSuffixMax diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift new file mode 100644 index 00000000..fc38ab06 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift @@ -0,0 +1,153 @@ +// +// QueryCommand+FilterParsing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +extension QueryCommand { + /// Parse a single filter expression "field:operator:value" into a QueryFilter + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func parseFilter(_ filterString: String) throws -> QueryFilter { + let components = filterString.split( + separator: ":", maxSplits: 2, omittingEmptySubsequences: false + ) + + guard components.count == 3 else { + throw QueryError.invalidFilter(filterString, expected: "field:operator:value") + } + + let field = String(components[0]).trimmingCharacters(in: .whitespaces) + let operatorString = String(components[1]).trimmingCharacters(in: .whitespaces) + let value = String(components[2]) + + guard !field.isEmpty else { + throw QueryError.emptyFieldName(filterString) + } + + return try buildFilter(field: field, operatorString: operatorString, value: value) + } + + /// Build a QueryFilter from parsed components. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func buildFilter( + field: String, + operatorString: String, + value: String + ) throws -> QueryFilter { + if let comparison = buildComparisonFilter( + field: field, operatorString: operatorString, value: value + ) { + return comparison + } + return try buildSpecialFilter( + field: field, operatorString: operatorString, value: value + ) + } + + /// Build comparison-based filters (equals, not equals, greater/less than). + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + // swiftlint:disable:next cyclomatic_complexity + internal static func buildComparisonFilter( + field: String, + operatorString: String, + value: String + ) -> QueryFilter? { + switch operatorString.lowercased() { + case "eq", "equals", "==", "=": + return .equals(field, inferFieldValue(value)) + case "ne", "not_equals", "!=": + return .notEquals(field, inferFieldValue(value)) + case "gt", ">": + return .greaterThan(field, inferFieldValue(value)) + case "gte", ">=": + return .greaterThanOrEquals( + field, inferFieldValue(value) + ) + case "lt", "<": + return .lessThan(field, inferFieldValue(value)) + case "lte", "<=": + return .lessThanOrEquals( + field, inferFieldValue(value) + ) + default: + return nil + } + } + + /// Build string and list-based filters. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func buildSpecialFilter( + field: String, + operatorString: String, + value: String + ) throws -> QueryFilter { + switch operatorString.lowercased() { + case "contains", "like": + return .containsAllTokens(field, value) + case "begins_with", "starts_with": + return .beginsWith(field, value) + case "in": + let values = value.split(separator: ",").map { + inferFieldValue(String($0)) + } + return .in(field, values) + case "not_in": + let values = value.split(separator: ",").map { + inferFieldValue(String($0)) + } + return .notIn(field, values) + default: + throw QueryError.unsupportedOperator(operatorString) + } + } + + /// Infer a FieldValue from a string. + internal static func inferFieldValue( + _ string: String + ) -> FieldValue { + if let intValue = Int64(string) { + return .int64(Int(intValue)) + } + if let doubleValue = Double(string) { + return .double(doubleValue) + } + return .string(string) + } + + /// Check if a field should be included based on field filter + internal static func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { + guard let fields = fields, !fields.isEmpty else { + return true // Include all fields if no filter specified + } + + return fields.contains { requestedField in + fieldName.lowercased() == requestedField.lowercased() + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index 07ca4d1a..9bd520d5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -76,7 +76,7 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { let filters: [QueryFilter]? = config.filters.isEmpty ? nil - : try config.filters.map { try parseFilter($0) } + : try config.filters.map { try Self.parseFilter($0) } recordInfos = try await client.queryRecords( recordType: config.recordType, filters: filters, @@ -98,126 +98,6 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { throw QueryError.operationFailed(error.localizedDescription) } } - - /// Parse a single filter expression "field:operator:value" into a QueryFilter - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - private func parseFilter(_ filterString: String) throws -> QueryFilter { - let components = filterString.split( - separator: ":", maxSplits: 2, omittingEmptySubsequences: false - ) - - guard components.count == 3 else { - throw QueryError.invalidFilter(filterString, expected: "field:operator:value") - } - - let field = String(components[0]).trimmingCharacters(in: .whitespaces) - let operatorString = String(components[1]).trimmingCharacters(in: .whitespaces) - let value = String(components[2]) - - guard !field.isEmpty else { - throw QueryError.emptyFieldName(filterString) - } - - return try buildFilter(field: field, operatorString: operatorString, value: value) - } - - /// Build a QueryFilter from parsed components. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - private func buildFilter( - field: String, - operatorString: String, - value: String - ) throws -> QueryFilter { - if let comparison = buildComparisonFilter( - field: field, operatorString: operatorString, value: value - ) { - return comparison - } - return try buildSpecialFilter( - field: field, operatorString: operatorString, value: value - ) - } - - // Build comparison-based filters (equals, not equals, greater/less than). - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - // swiftlint:disable:next cyclomatic_complexity - private func buildComparisonFilter( - field: String, - operatorString: String, - value: String - ) -> QueryFilter? { - switch operatorString.lowercased() { - case "eq", "equals", "==", "=": - return .equals(field, inferFieldValue(value)) - case "ne", "not_equals", "!=": - return .notEquals(field, inferFieldValue(value)) - case "gt", ">": - return .greaterThan(field, inferFieldValue(value)) - case "gte", ">=": - return .greaterThanOrEquals( - field, inferFieldValue(value) - ) - case "lt", "<": - return .lessThan(field, inferFieldValue(value)) - case "lte", "<=": - return .lessThanOrEquals( - field, inferFieldValue(value) - ) - default: - return nil - } - } - - /// Build string and list-based filters. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - private func buildSpecialFilter( - field: String, - operatorString: String, - value: String - ) throws -> QueryFilter { - switch operatorString.lowercased() { - case "contains", "like": - return .containsAllTokens(field, value) - case "begins_with", "starts_with": - return .beginsWith(field, value) - case "in": - let values = value.split(separator: ",").map { - inferFieldValue(String($0)) - } - return .in(field, values) - case "not_in": - let values = value.split(separator: ",").map { - inferFieldValue(String($0)) - } - return .notIn(field, values) - default: - throw QueryError.unsupportedOperator(operatorString) - } - } - - /// Infer a FieldValue from a string. - private func inferFieldValue( - _ string: String - ) -> FieldValue { - if let intValue = Int64(string) { - return .int64(Int(intValue)) - } - if let doubleValue = Double(string) { - return .double(doubleValue) - } - return .string(string) - } - - /// Check if a field should be included based on field filter - private func shouldIncludeField(_ fieldName: String, fields: [String]?) -> Bool { - guard let fields = fields, !fields.isEmpty else { - return true // Include all fields if no filter specified - } - - return fields.contains { requestedField in - fieldName.lowercased() == requestedField.lowercased() - } - } } // QueryError is now defined in Errors/QueryError.swift diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift new file mode 100644 index 00000000..84db2408 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CreateCommand/CreateCommandTests+GenerateRecordName.swift @@ -0,0 +1,80 @@ +// +// CreateCommandTests+GenerateRecordName.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +extension CreateCommandTests { + @Suite("generateRecordName helper") + internal struct GenerateRecordNameHelper { + @Test("generateRecordName prefixes with lowercased record type") + internal func lowercasePrefix() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Article") + let command = CreateCommand(config: config) + + let name = command.generateRecordName() + + #expect(name.hasPrefix("article-")) + } + + @Test("generateRecordName format is --<4-digit suffix>") + internal func threePartFormat() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Note") + let command = CreateCommand(config: config) + + let name = command.generateRecordName() + let parts = name.split(separator: "-").map(String.init) + + #expect(parts.count == 3) + #expect(parts[0] == "note") + #expect(Int(parts[1]) != nil, "expected a unix timestamp; got \(parts[1])") + let suffix = try #require(Int(parts[2])) + #expect(suffix >= MistDemoConstants.Limits.randomSuffixMin) + #expect(suffix <= MistDemoConstants.Limits.randomSuffixMax) + } + + @Test("generateRecordName produces distinct values across many calls") + internal func distinctness() async throws { + let baseConfig = try await MistDemoConfig() + let config = CreateConfig(base: baseConfig, recordType: "Note") + let command = CreateCommand(config: config) + + // The random suffix has ~9000 values; 200 samples should be highly unique. + // Allow some collisions but require that most samples are distinct, which + // verifies the random component is being used. + let names = (0..<200).map { _ in command.generateRecordName() } + let unique = Set(names) + #expect(unique.count > 150) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift new file mode 100644 index 00000000..fc4945ef --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/DemoErrorsRunnerOutputTests.swift @@ -0,0 +1,70 @@ +// +// DemoErrorsRunnerOutputTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("DemoErrorsRunner output helpers") +internal struct DemoErrorsRunnerOutputTests { + @Test("describe(nil) returns the placeholder") + internal func describeNil() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe(nil) == "") + } + + @Test("describe(\"\") returns the placeholder") + internal func describeEmpty() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe("") == "") + } + + @Test("describe echoes a non-empty tag verbatim") + internal func describeNonEmpty() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + #expect(runner.describe("rec-tag-1") == "rec-tag-1") + } + + @Test("describe preserves whitespace in a non-empty tag") + internal func describePreservesWhitespace() async throws { + let config = try await MistDemoConfig() + let runner = DemoErrorsRunner(config: config) + + // Only fully empty strings are normalized to ; + // whitespace-only tags are kept as-is. + #expect(runner.describe(" ") == " ") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift new file mode 100644 index 00000000..8b664b51 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ParseFilter.swift @@ -0,0 +1,189 @@ +// +// QueryCommandTests+ParseFilter.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +extension QueryCommandTests { + @Suite("parseFilter / inferFieldValue / shouldIncludeField") + internal struct ParseFilter { + // MARK: - inferFieldValue + + @Test("inferFieldValue parses integer literals as .int64") + internal func inferInt() { + #expect(QueryCommand.inferFieldValue("42") == .int64(42)) + #expect(QueryCommand.inferFieldValue("0") == .int64(0)) + #expect(QueryCommand.inferFieldValue("-7") == .int64(-7)) + } + + @Test("inferFieldValue parses non-integer numeric literals as .double") + internal func inferDouble() { + #expect(QueryCommand.inferFieldValue("3.14") == .double(3.14)) + #expect(QueryCommand.inferFieldValue("-2.5") == .double(-2.5)) + } + + @Test("inferFieldValue treats unparseable input as .string") + internal func inferString() { + #expect(QueryCommand.inferFieldValue("hello") == .string("hello")) + #expect(QueryCommand.inferFieldValue("12abc") == .string("12abc")) + #expect(QueryCommand.inferFieldValue("") == .string("")) + } + + // MARK: - shouldIncludeField + + @Test("shouldIncludeField returns true when filter is nil or empty") + internal func includeAllByDefault() { + #expect(QueryCommand.shouldIncludeField("title", fields: nil) == true) + #expect(QueryCommand.shouldIncludeField("title", fields: []) == true) + } + + @Test("shouldIncludeField matches case-insensitively") + internal func caseInsensitiveMatch() { + #expect(QueryCommand.shouldIncludeField("Title", fields: ["title"]) == true) + #expect(QueryCommand.shouldIncludeField("title", fields: ["TITLE"]) == true) + #expect(QueryCommand.shouldIncludeField("Body", fields: ["title", "body"]) == true) + } + + @Test("shouldIncludeField excludes fields not in filter") + internal func excludesNonMatches() { + #expect(QueryCommand.shouldIncludeField("priority", fields: ["title"]) == false) + #expect(QueryCommand.shouldIncludeField("body", fields: ["title", "priority"]) == false) + } + + // MARK: - parseFilter — happy paths + + @Test( + "parseFilter accepts comparison operators", + arguments: [ + "title:eq:hello", + "title:equals:hello", + "title:==:hello", + "title:=:hello", + "priority:ne:1", + "priority:not_equals:1", + "priority:!=:1", + "score:gt:10", + "score:>:10", + "score:gte:10", + "score:>=:10", + "score:lt:10", + "score:<:10", + "score:lte:10", + "score:<=:10", + ] + ) + internal func parsesComparisonOperators(filterString: String) throws { + _ = try QueryCommand.parseFilter(filterString) + } + + @Test( + "parseFilter accepts string and list operators", + arguments: [ + "title:contains:hello world", + "title:like:hello world", + "title:begins_with:hello", + "title:starts_with:hello", + "priority:in:1,2,3", + "priority:not_in:1,2,3", + ] + ) + internal func parsesSpecialOperators(filterString: String) throws { + _ = try QueryCommand.parseFilter(filterString) + } + + @Test("parseFilter accepts operator names in any case") + internal func operatorCaseInsensitive() throws { + _ = try QueryCommand.parseFilter("title:EQ:hello") + _ = try QueryCommand.parseFilter("title:Equals:hello") + _ = try QueryCommand.parseFilter("title:BEGINS_WITH:hello") + } + + @Test("parseFilter preserves colons in value (maxSplits=2)") + internal func valueWithColons() throws { + _ = try QueryCommand.parseFilter("url:eq:https://example.com:8080/path") + } + + // MARK: - parseFilter — error paths + + @Test("parseFilter throws invalidFilter when fewer than three components") + internal func tooFewComponentsThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("title:eq") + } + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("nothing") + } + } + + @Test("parseFilter throws emptyFieldName when the field segment is blank") + internal func emptyFieldThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter(":eq:value") + } + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter(" :eq:value") + } + } + + @Test("parseFilter throws unsupportedOperator for an unknown operator") + internal func unsupportedOperatorThrows() { + #expect(throws: QueryError.self) { + _ = try QueryCommand.parseFilter("title:fuzzy_match:hello") + } + } + + // MARK: - buildComparisonFilter + + @Test("buildComparisonFilter returns nil for non-comparison operators") + internal func buildComparisonFilterReturnsNilForSpecial() { + let result = QueryCommand.buildComparisonFilter( + field: "title", + operatorString: "contains", + value: "hello" + ) + #expect(result == nil) + } + + @Test( + "buildComparisonFilter returns a filter for each comparison alias", + arguments: ["eq", "ne", "gt", "gte", "lt", "lte"] + ) + internal func buildComparisonFilterReturnsNonNil(alias: String) { + let result = QueryCommand.buildComparisonFilter( + field: "score", + operatorString: alias, + value: "10" + ) + #expect(result != nil) + } + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift new file mode 100644 index 00000000..3c8c8ff2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -0,0 +1,133 @@ +// +// AuthTokenConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Configuration +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("AuthTokenConfig Tests") +internal struct AuthTokenConfigTests { + private static func key(_ path: String) -> AbsoluteConfigKey { + AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) + } + + private static func configuration( + values: [String: ConfigValue] + ) -> MistDemoConfiguration { + var mapped: [AbsoluteConfigKey: ConfigValue] = [:] + for (path, value) in values { + mapped[key(path)] = value + } + return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + } + + @Test("Memberwise init applies defaults for port, host, noBrowser, container") + internal func memberwiseDefaults() { + let config = AuthTokenConfig(apiToken: "tok") + + #expect(config.apiToken == "tok") + #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.port == 8_080) + #expect(config.host == "127.0.0.1") + #expect(config.noBrowser == false) + } + + @Test("Memberwise init accepts custom values for every field") + internal func memberwiseCustom() { + let config = AuthTokenConfig( + apiToken: "tok", + containerIdentifier: "iCloud.custom.id", + port: 9_000, + host: "0.0.0.0", + noBrowser: true + ) + + #expect(config.apiToken == "tok") + #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.port == 9_000) + #expect(config.host == "0.0.0.0") + #expect(config.noBrowser == true) + } + + @Test("Configuration init throws missingRequired when api.token is absent") + internal func missingApiTokenThrows() async { + let configuration = Self.configuration(values: [:]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } + } + + @Test("Configuration init throws missingRequired when api.token is empty") + internal func emptyApiTokenThrows() async { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "") + ]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } + } + + @Test("Configuration init applies all defaults when only api.token is set") + internal func parsedDefaults() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz") + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.apiToken == "tok-xyz") + #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.port == 8_080) + #expect(config.host == "127.0.0.1") + #expect(config.noBrowser == false) + } + + @Test("Configuration init honors every override key") + internal func parsedOverrides() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "container.identifier": .init(stringLiteral: "iCloud.custom.id"), + "port": .init(integerLiteral: 9_090), + "host": .init(stringLiteral: "192.168.1.10"), + "no.browser": .init(booleanLiteral: true), + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.apiToken == "tok-xyz") + #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.port == 9_090) + #expect(config.host == "192.168.1.10") + #expect(config.noBrowser == true) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift new file mode 100644 index 00000000..94bc2ffa --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift @@ -0,0 +1,83 @@ +// +// TestIntegrationConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("TestIntegrationConfig Tests") +internal struct TestIntegrationConfigTests { + @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestIntegrationConfig(base: baseConfig) + + #expect(config.recordCount == 10) + #expect(config.assetSizeKB == 100) + #expect(config.skipCleanup == false) + #expect(config.verbose == false) + #expect(config.lookupEmail == nil) + } + + @Test("Memberwise init accepts custom values") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestIntegrationConfig( + base: baseConfig, + recordCount: 25, + assetSizeKB: 512, + skipCleanup: true, + verbose: true, + lookupEmail: "user@example.com" + ) + + #expect(config.recordCount == 25) + #expect(config.assetSizeKB == 512) + #expect(config.skipCleanup == true) + #expect(config.verbose == true) + #expect(config.lookupEmail == "user@example.com") + } + + @Test("Memberwise init preserves base configuration values") + internal func preservesBase() async throws { + let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.integration.test") + let config = TestIntegrationConfig(base: baseConfig) + + #expect(config.base.containerIdentifier == "iCloud.integration.test") + } + + @Test("Memberwise init accepts zero recordCount") + internal func zeroRecordCount() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestIntegrationConfig(base: baseConfig, recordCount: 0) + + #expect(config.recordCount == 0) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift new file mode 100644 index 00000000..bf3047a4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -0,0 +1,89 @@ +// +// TestPrivateConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit +import Testing + +@testable import MistDemoKit + +@Suite("TestPrivateConfig Tests") +internal struct TestPrivateConfigTests { + @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPrivateConfig(base: baseConfig) + + #expect(config.recordCount == 10) + #expect(config.assetSizeKB == 100) + #expect(config.skipCleanup == false) + #expect(config.verbose == false) + #expect(config.lookupEmail == nil) + } + + @Test("Memberwise init accepts every custom value") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = TestPrivateConfig( + base: baseConfig, + recordCount: 42, + assetSizeKB: 2_048, + skipCleanup: true, + verbose: true, + lookupEmail: "user@example.com" + ) + + #expect(config.recordCount == 42) + #expect(config.assetSizeKB == 2_048) + #expect(config.skipCleanup == true) + #expect(config.verbose == true) + #expect(config.lookupEmail == "user@example.com") + } + + @Test("Configuration init pins database to private regardless of input") + internal func pinsDatabaseToPrivate() async throws { + // Even though we configure the base for the public DB, TestPrivateConfig + // must override to `.private`. The init also requires web-auth credentials. + let baseConfig = try await MistDemoConfig( + database: .public, + webAuthToken: "wat-xyz" + ) + let config = TestPrivateConfig(base: baseConfig.with(database: .private)) + + #expect(config.base.database == .private) + } + + @Test("Memberwise init preserves base configuration values") + internal func preservesBase() async throws { + let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.private.test") + let config = TestPrivateConfig(base: baseConfig) + + #expect(config.base.containerIdentifier == "iCloud.private.test") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift new file mode 100644 index 00000000..cecb0092 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/UploadAssetConfigTests.swift @@ -0,0 +1,102 @@ +// +// UploadAssetConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistDemoKit + +@Suite("UploadAssetConfig Tests") +internal struct UploadAssetConfigTests { + @Test("Memberwise init applies recordName=nil and json output by default") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/tmp/photo.jpg", + recordType: "Note", + fieldName: "image" + ) + + #expect(config.file == "/tmp/photo.jpg") + #expect(config.recordType == "Note") + #expect(config.fieldName == "image") + #expect(config.recordName == nil) + #expect(config.output == .json) + } + + @Test("Memberwise init accepts all custom values") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/var/data/photo.png", + recordType: "Photo", + fieldName: "thumbnail", + recordName: "rec-123", + output: .yaml + ) + + #expect(config.file == "/var/data/photo.png") + #expect(config.recordType == "Photo") + #expect(config.fieldName == "thumbnail") + #expect(config.recordName == "rec-123") + #expect(config.output == .yaml) + } + + @Test( + "UploadAssetConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/tmp/photo.jpg", + recordType: "Note", + fieldName: "image", + output: format + ) + + #expect(config.output == format) + } + + @Test("UploadAssetConfig preserves a file path containing spaces") + internal func pathWithSpaces() async throws { + let baseConfig = try await MistDemoConfig() + let config = UploadAssetConfig( + base: baseConfig, + file: "/var/data/My Photos/img.jpg", + recordType: "Note", + fieldName: "image" + ) + + #expect(config.file == "/var/data/My Photos/img.jpg") + } +}